code-coordinator 0.5.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,240 @@
1
+ """The check registry (#1628).
2
+
3
+ **The acceptance bar for this abstraction is that adding a check touches
4
+ exactly one file: the new check module.** Not the renderer, not the CLI,
5
+ not a transport. That is enforced two ways:
6
+
7
+ 1. Registration is a decorator (:func:`check`) applied in the check's own
8
+ module — there is no central list of checks to append to.
9
+ 2. Discovery is :func:`pkgutil.iter_modules` over ``coord.health.checks`` —
10
+ dropping ``coord/health/checks/foo.py`` into the package is the whole
11
+ installation step. ``checks/__init__.py`` deliberately imports nothing.
12
+
13
+ The other half of the contract is fail-soft. :func:`run_all` wraps every
14
+ probe in a bare ``except Exception`` and converts a raised probe into an
15
+ ``unknown`` result carrying the error text. A health engine that dies on
16
+ its weakest check reports nothing, which is worse than reporting the rest
17
+ plus one ``?``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import dataclasses
23
+ import importlib
24
+ import pkgutil
25
+ from collections.abc import Callable, Iterable, Sequence
26
+ from dataclasses import dataclass, field
27
+ from typing import Any
28
+
29
+ from coord.health.models import (
30
+ SCOPES,
31
+ CheckResult,
32
+ HealthContext,
33
+ Severity,
34
+ unknown_result,
35
+ )
36
+
37
+ # What a probe costs to run. ``cheap`` probes are local syscalls/subprocesses
38
+ # and the whole cheap set is budgeted under ~2s, because this eventually runs
39
+ # on a timer on every agent. ``network`` probes (the one PyPI simple-index
40
+ # fetch, the ``claude -p /usage`` round trip) are skipped when the caller
41
+ # passes ``allow_network=False``.
42
+ COST_CHEAP = "cheap"
43
+ COST_NETWORK = "network"
44
+
45
+ ProbeFn = Callable[[HealthContext], CheckResult | Sequence[CheckResult] | None]
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Check:
50
+ """A self-contained unit of fleet-degradation signal."""
51
+
52
+ id: str
53
+ scope: str
54
+ probe: ProbeFn
55
+ title: str = ""
56
+ description: str = ""
57
+ cost: str = COST_CHEAP
58
+ # Display/run ordering. Lives on the check so a new module can slot
59
+ # itself into the report without anyone editing a renderer's list.
60
+ order: int = 100
61
+
62
+ def __post_init__(self) -> None:
63
+ if self.scope not in SCOPES:
64
+ raise ValueError(f"check {self.id!r}: scope must be one of {SCOPES}, got {self.scope!r}")
65
+ if self.cost not in (COST_CHEAP, COST_NETWORK):
66
+ raise ValueError(f"check {self.id!r}: cost must be 'cheap' or 'network'")
67
+ if not self.title:
68
+ object.__setattr__(self, "title", self.id)
69
+
70
+
71
+ _REGISTRY: dict[str, Check] = {}
72
+ _discovered = False
73
+
74
+
75
+ def register(chk: Check) -> Check:
76
+ """Add *chk* to the registry. Re-registering the same id replaces it
77
+ (module reload under pytest must not raise)."""
78
+ _REGISTRY[chk.id] = chk
79
+ return chk
80
+
81
+
82
+ def check(
83
+ *,
84
+ id: str, # noqa: A002 — matches the field name in the issue's spec
85
+ scope: str,
86
+ title: str = "",
87
+ description: str = "",
88
+ cost: str = COST_CHEAP,
89
+ order: int = 100,
90
+ ) -> Callable[[ProbeFn], ProbeFn]:
91
+ """Decorator form: register the decorated function as a check's probe.
92
+
93
+ The probe returns one :class:`CheckResult`, a sequence of them (one per
94
+ disk / per checkout / ...), or ``None`` for "nothing to report here".
95
+ """
96
+
97
+ def _wrap(fn: ProbeFn) -> ProbeFn:
98
+ register(
99
+ Check(
100
+ id=id,
101
+ scope=scope,
102
+ probe=fn,
103
+ title=title,
104
+ description=description,
105
+ cost=cost,
106
+ order=order,
107
+ )
108
+ )
109
+ return fn
110
+
111
+ return _wrap
112
+
113
+
114
+ def discover(force: bool = False) -> None:
115
+ """Import every module under ``coord.health.checks`` so its decorators run.
116
+
117
+ This is the *entire* registration mechanism. There is no list to edit.
118
+ """
119
+ global _discovered
120
+ if _discovered and not force:
121
+ return
122
+ from coord.health import checks as _checks_pkg
123
+
124
+ for mod in pkgutil.iter_modules(_checks_pkg.__path__):
125
+ if mod.name.startswith("_"):
126
+ continue
127
+ importlib.import_module(f"{_checks_pkg.__name__}.{mod.name}")
128
+ _discovered = True
129
+
130
+
131
+ def all_checks() -> list[Check]:
132
+ """Every registered check, in stable report order."""
133
+ discover()
134
+ return sorted(_REGISTRY.values(), key=lambda c: (c.order, c.id))
135
+
136
+
137
+ def get(check_id: str) -> Check | None:
138
+ discover()
139
+ return _REGISTRY.get(check_id)
140
+
141
+
142
+ def run_check(chk: Check, ctx: HealthContext) -> list[CheckResult]:
143
+ """Run one check, fail-soft.
144
+
145
+ A probe that raises — any exception, including ``KeyboardInterrupt``'s
146
+ non-``Exception`` siblings excluded — yields a single ``unknown`` result
147
+ naming the error, and the run continues.
148
+ """
149
+ try:
150
+ out = chk.probe(ctx)
151
+ except Exception as exc: # noqa: BLE001 — fail soft is the requirement
152
+ return [
153
+ unknown_result(
154
+ chk.id,
155
+ scope=chk.scope,
156
+ title=chk.title,
157
+ error=f"{type(exc).__name__}: {exc}",
158
+ )
159
+ ]
160
+ if out is None:
161
+ return []
162
+ results = [out] if isinstance(out, CheckResult) else list(out)
163
+ # A probe that forgets its own title/scope shouldn't produce rows the
164
+ # renderer can't label. Backfill from the check definition.
165
+ return [
166
+ dataclasses.replace(r, title=chk.title) if r.title == r.check_id else r
167
+ for r in results
168
+ ]
169
+
170
+
171
+ @dataclass
172
+ class HealthReport:
173
+ """The full outcome of one registry run."""
174
+
175
+ results: list[CheckResult] = field(default_factory=list)
176
+ skipped: list[str] = field(default_factory=list)
177
+ duration_secs: float = 0.0
178
+
179
+ @property
180
+ def severity(self) -> Severity:
181
+ from coord.health.models import worst
182
+
183
+ return worst([r.severity for r in self.results])
184
+
185
+ def counts(self) -> dict[str, int]:
186
+ out = {s.value: 0 for s in Severity}
187
+ for r in self.results:
188
+ out[r.severity.value] += 1
189
+ return out
190
+
191
+ def to_dict(self) -> dict[str, Any]:
192
+ """The ``coord health --json`` contract (H-3/H-4 consume this)."""
193
+ return {
194
+ "schema": 1,
195
+ "severity": self.severity.value,
196
+ "counts": self.counts(),
197
+ "skipped": list(self.skipped),
198
+ "duration_secs": round(self.duration_secs, 3),
199
+ "results": [r.to_dict() for r in self.results],
200
+ }
201
+
202
+
203
+ def run_all(
204
+ ctx: HealthContext,
205
+ *,
206
+ scopes: Iterable[str] | None = None,
207
+ only: Iterable[str] | None = None,
208
+ ) -> HealthReport:
209
+ """Run the registry against *ctx*.
210
+
211
+ * ``scopes`` — restrict to these scopes (``coord health --local`` passes
212
+ ``("machine", "checkout")``; ``fleet`` probes arrive in H-3).
213
+ * ``only`` — restrict to these check ids.
214
+ * ``ctx.allow_network`` False skips ``cost="network"`` checks, recording
215
+ their ids in :attr:`HealthReport.skipped` so "we didn't look" never
216
+ reads as "nothing wrong".
217
+ * ``thresholds.disabled_checks`` skips by operator config, same way.
218
+ """
219
+ import time # noqa: PLC0415 — local so a frozen-clock test can patch it
220
+
221
+ started = time.monotonic()
222
+ scope_filter = set(scopes) if scopes is not None else None
223
+ only_filter = set(only) if only is not None else None
224
+ disabled = set(getattr(ctx.thresholds, "disabled_checks", ()) or ())
225
+
226
+ report = HealthReport()
227
+ for chk in all_checks():
228
+ if scope_filter is not None and chk.scope not in scope_filter:
229
+ continue
230
+ if only_filter is not None and chk.id not in only_filter:
231
+ continue
232
+ if chk.id in disabled:
233
+ report.skipped.append(f"{chk.id} (disabled in coordinator.yml)")
234
+ continue
235
+ if chk.cost == COST_NETWORK and not ctx.allow_network:
236
+ report.skipped.append(f"{chk.id} (network probe, --no-network)")
237
+ continue
238
+ report.results.extend(run_check(chk, ctx))
239
+ report.duration_secs = time.monotonic() - started
240
+ return report
coord/health/render.py ADDED
@@ -0,0 +1,82 @@
1
+ """Text rendering for ``coord health`` (#1628).
2
+
3
+ **This module never decides anything.** It reads ``result.severity`` and
4
+ ``result.headroom`` and lays them out; it does not look at ``result.values``
5
+ except to pass it through, and it does not know what any particular check
6
+ measures. That is the whole reason a check can be added without editing a
7
+ renderer — and the reason H-3's board projection and H-4's TUI/web renderers
8
+ can be written against the same :meth:`CheckResult.to_dict` contract without
9
+ re-deriving "is 86% used bad?" three more times.
10
+
11
+ If you find yourself wanting to special-case a check id in here, the thing
12
+ you actually want is another rendered field on ``CheckResult`` (like
13
+ ``threshold`` or ``trend``), populated by the probe.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from coord.health.models import CheckResult, Severity
19
+ from coord.health.registry import HealthReport
20
+
21
+ # Minimum column widths. Columns grow to fit the widest row in the report
22
+ # (see :func:`render_report`) rather than truncating — a clipped repo name or
23
+ # path in a health report costs more than a ragged right edge.
24
+ _LABEL_WIDTH = 20
25
+ _SEVERITY_WIDTH = 5
26
+ _HEADROOM_WIDTH = 46
27
+ # Don't let one pathological label push every other column off the screen.
28
+ _MAX_LABEL_WIDTH = 40
29
+
30
+
31
+ def render_result(result: CheckResult, *, label_width: int = _LABEL_WIDTH) -> str:
32
+ """One report line: ``<label> <SEVERITY> <headroom> <threshold>``."""
33
+ label = result.label.ljust(label_width)
34
+ severity = result.severity.label.ljust(_SEVERITY_WIDTH)
35
+ headroom = result.headroom
36
+ trailer_parts = []
37
+ if result.trend:
38
+ trailer_parts.append(result.trend)
39
+ # The threshold reminder is only useful next to a number that is near it.
40
+ # On an OK line it is clutter, so it rides along only when something is up.
41
+ if result.threshold and result.severity is not Severity.OK:
42
+ trailer_parts.append(result.threshold)
43
+ trailer = " ".join(trailer_parts)
44
+ line = f"{label} {severity} {headroom}"
45
+ if trailer:
46
+ line = f"{line.ljust(label_width + _SEVERITY_WIDTH + _HEADROOM_WIDTH + 4)} {trailer}"
47
+ return line.rstrip()
48
+
49
+
50
+ def render_report(report: HealthReport, *, verbose: bool = False) -> str:
51
+ """The full ``coord health`` body.
52
+
53
+ ``verbose`` adds each result's ``detail`` (the "fix: ..." line) as an
54
+ indented continuation; without it only the headroom is shown, which is
55
+ what makes the report scannable at a glance.
56
+ """
57
+ label_width = min(
58
+ _MAX_LABEL_WIDTH,
59
+ max([_LABEL_WIDTH, *(len(r.label) for r in report.results)]),
60
+ )
61
+ lines: list[str] = []
62
+ for result in report.results:
63
+ lines.append(render_result(result, label_width=label_width))
64
+ if result.detail and (verbose or result.severity is not Severity.OK):
65
+ lines.append(f"{' ' * (label_width + 2)} {result.detail}")
66
+ if not lines:
67
+ lines.append("no checks ran")
68
+ for skipped in report.skipped:
69
+ lines.append(f"{'skipped'.ljust(label_width)} - {skipped}")
70
+ lines.append(render_summary(report))
71
+ return "\n".join(lines)
72
+
73
+
74
+ def render_summary(report: HealthReport) -> str:
75
+ """The trailer line — machine-greppable, same shape as GRAPH_HEALTH's."""
76
+ counts = report.counts()
77
+ return (
78
+ f"HEALTH: {report.severity.label} "
79
+ f"crit={counts['crit']} warn={counts['warn']} "
80
+ f"unknown={counts['unknown']} ok={counts['ok']} "
81
+ f"in {report.duration_secs:.2f}s"
82
+ )
coord/health/units.py ADDED
@@ -0,0 +1,60 @@
1
+ """Tiny rendering helpers shared by probes (#1628).
2
+
3
+ These live next to the probes, not in the renderer, on purpose: a probe owns
4
+ its ``headroom`` string end to end (see ``coord.health.models``), so the
5
+ byte/duration formatting it needs has to be reachable without importing a
6
+ renderer. A renderer importing *this* is fine; a probe importing a renderer
7
+ is the fork we're preventing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ _GIB = 1024.0 ** 3
15
+
16
+
17
+ def human_bytes(n: float) -> str:
18
+ """``78G`` / ``512M`` / ``0B`` — short enough for a one-line report."""
19
+ n = float(n)
20
+ sign = "-" if n < 0 else ""
21
+ n = abs(n)
22
+ for unit, size in (("T", 1024.0 ** 4), ("G", _GIB), ("M", 1024.0 ** 2), ("K", 1024.0)):
23
+ if n >= size:
24
+ value = n / size
25
+ # 43.2G reads better than 43G below 10; above that the decimal is noise.
26
+ return f"{sign}{value:.1f}{unit}" if value < 10 else f"{sign}{value:.0f}{unit}"
27
+ return f"{sign}{n:.0f}B"
28
+
29
+
30
+ def gib(n: float) -> float:
31
+ """Bytes → GiB, as a float."""
32
+ return float(n) / _GIB
33
+
34
+
35
+ def human_hours(seconds: float) -> str:
36
+ """``128.8h`` — the unit the graph-staleness incident was reported in."""
37
+ return f"{seconds / 3600.0:.1f}h"
38
+
39
+
40
+ def expand(path: str | Path, home: str | Path) -> Path:
41
+ """``~``/``~/x`` → under *home*, everything else verbatim.
42
+
43
+ Deliberately NOT ``Path.expanduser()``: probes must expand against the
44
+ context's home so a test can point the whole engine at a tmp dir without
45
+ monkeypatching ``Path.home`` globally.
46
+ """
47
+ s = str(path)
48
+ if s == "~":
49
+ return Path(home)
50
+ if s.startswith("~/"):
51
+ return Path(home) / s[2:]
52
+ return Path(s)
53
+
54
+
55
+ def shorten_path(path: str, home: str) -> str:
56
+ """``/home/john/src/vimcode`` → ``~/src/vimcode`` when it's under *home*."""
57
+ p, h = str(path), str(home).rstrip("/")
58
+ if h and (p == h or p.startswith(h + "/")):
59
+ return "~" + p[len(h):]
60
+ return p
coord/hooks.py ADDED
@@ -0,0 +1,106 @@
1
+ """Session lifecycle hooks — triggered at round completion and session end."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Callable
7
+
8
+ from coord import github_ops
9
+ from coord.config import Config
10
+ from coord.models import CLOSES_ISSUE_TYPES, Board
11
+
12
+
13
+ @dataclass
14
+ class HookResult:
15
+ hook: str
16
+ ok: bool
17
+ message: str
18
+
19
+
20
+ def run_hooks(event: str, config: Config, board: Board) -> list[HookResult]:
21
+ """Execute all hooks registered for *event*. Returns results."""
22
+ hook_names = getattr(config.hooks, event, [])
23
+ results: list[HookResult] = []
24
+ for name in hook_names:
25
+ fn = HOOK_REGISTRY.get(name)
26
+ if fn is None:
27
+ results.append(HookResult(hook=name, ok=False, message=f"unknown hook: {name}"))
28
+ continue
29
+ try:
30
+ msg = fn(config, board)
31
+ results.append(HookResult(hook=name, ok=True, message=msg))
32
+ except Exception as e:
33
+ results.append(HookResult(hook=name, ok=False, message=str(e)))
34
+ return results
35
+
36
+
37
+ def is_round_complete(board: Board) -> bool:
38
+ """True when no active assignments remain (all finished or failed)."""
39
+ return len(board.active) == 0 and len(board.completed) > 0
40
+
41
+
42
+ # ── Built-in hooks ──────────────────────────────────────────────────────────
43
+
44
+
45
+ def _close_merged_issues(config: Config, board: Board) -> str:
46
+ """Close issues whose assignments completed successfully.
47
+
48
+ #1196 (hole 3): gated on :data:`coord.models.CLOSES_ISSUE_TYPES`, the
49
+ same gate ``merge_queue.py`` and the PR-body keyword already honour. An
50
+ opt-in hook (``KNOWN_HOOKS``, defaults empty — not enabled in this
51
+ repo's own config) must not contradict the #1077 invariant: a
52
+ "mock-author"/"test-author"/"audit" assignment's ``issue_number`` is the
53
+ milestone's *tracking issue* (often an epic), not something it resolved
54
+ — closing it here regardless of type was a loaded gun. Also routes
55
+ through :func:`coord.github_ops.close_issue` — the #1196 open-children
56
+ chokepoint — instead of a raw ``gh issue close`` call, so an epic with
57
+ open children is refused here too, not just on the merge path.
58
+ """
59
+ closed = []
60
+ for a in board.completed:
61
+ if a.status != "done":
62
+ continue
63
+ if a.type not in CLOSES_ISSUE_TYPES:
64
+ continue
65
+ repo = config.repo(a.repo_name)
66
+ if repo is None:
67
+ continue
68
+ try:
69
+ github_ops.close_issue(
70
+ repo.github,
71
+ a.issue_number,
72
+ comment=f"Closed by coordinator: assignment {a.assignment_id} completed.",
73
+ )
74
+ closed.append(f"{repo.github}#{a.issue_number}")
75
+ except RuntimeError:
76
+ pass
77
+ if not closed:
78
+ return "no issues to close"
79
+ return f"closed {len(closed)} issue(s): {', '.join(closed)}"
80
+
81
+
82
+ def _summary_report(config: Config, board: Board) -> str:
83
+ """Generate a session/round summary."""
84
+ done = [a for a in board.completed if a.status == "done"]
85
+ failed = [a for a in board.completed if a.status == "failed"]
86
+ lines = [
87
+ f"Round {board.round_number} summary:",
88
+ f" completed: {len(done)} assignment(s)",
89
+ f" failed: {len(failed)} assignment(s)",
90
+ f" active: {len(board.active)} assignment(s) still running",
91
+ ]
92
+ if done:
93
+ lines.append(" done:")
94
+ for a in done:
95
+ lines.append(f" - {a.repo_name} #{a.issue_number}: {a.issue_title}")
96
+ if failed:
97
+ lines.append(" failed:")
98
+ for a in failed:
99
+ lines.append(f" - {a.repo_name} #{a.issue_number}: {a.issue_title}")
100
+ return "\n".join(lines)
101
+
102
+
103
+ HOOK_REGISTRY: dict[str, Callable[[Config, Board], str]] = {
104
+ "close_merged_issues": _close_merged_issues,
105
+ "summary_report": _summary_report,
106
+ }
coord/housekeeping.py ADDED
@@ -0,0 +1,204 @@
1
+ """#762: bound DB growth by archiving stale terminal board rows.
2
+
3
+ The ``coord serve`` board grew unbounded (every assignment ever dispatched stayed
4
+ in ``assignments`` forever), bloating the ``/board`` projection until it overran
5
+ the TUI's fetch timeout and blanked the whole board. Part 1 caps the *wire*
6
+ (``coord.dao.board_projection``); this module bounds the *storage*: it **moves**
7
+ (never deletes) terminal assignments older than the archive window — plus their
8
+ notifications — into ``assignments_archive`` / ``notifications_archive`` so the
9
+ hot tables stay small while the cost/token/timing history is preserved for
10
+ analytics.
11
+
12
+ Two guarantees make the sweep safe:
13
+
14
+ * It reuses :func:`coord.dao.compute_board_keep_ids` (the *same* keep logic as the
15
+ board projection) with a **wider** window than the projection, so the protected
16
+ set here is always a superset of what the projection keeps — archiving can never
17
+ drop a row the board still shows.
18
+ * It only ever archives rows whose status is terminal, as a belt-and-suspenders
19
+ guard on top of the keep set.
20
+
21
+ The sweep runs automatically on a low-cadence daemon tick and on demand via
22
+ ``coord housekeeping`` (which routes through the daemon — the canonical DB lives
23
+ there).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import sqlite3
30
+ import time
31
+
32
+ from coord.dao import (
33
+ TERMINAL_STATUSES,
34
+ _KEEP_INDEX_COLUMNS,
35
+ compute_board_keep_ids,
36
+ )
37
+ from coord.db import get_connection
38
+
39
+ # Terminal rows older than this many days (and not referenced by anything live)
40
+ # are eligible to move to the archive. Deliberately wider than the board
41
+ # projection window (``COORD_BOARD_RETENTION_DAYS``, default 14) so the live
42
+ # table always retains everything the wire shows, with margin for any logic that
43
+ # reaches back through ``build_board``. 0 disables archiving.
44
+ _DEFAULT_ARCHIVE_RETENTION_DAYS = 30
45
+
46
+ _ASSIGNMENTS = "assignments"
47
+ _ASSIGNMENTS_ARCHIVE = "assignments_archive"
48
+ _NOTIFICATIONS = "notifications"
49
+ _NOTIFICATIONS_ARCHIVE = "notifications_archive"
50
+ _BATCH = 400 # keep IN(...) clauses well under SQLite's 999-variable limit
51
+
52
+
53
+ def _archive_retention_days() -> int:
54
+ try:
55
+ return int(
56
+ os.environ.get(
57
+ "COORD_ARCHIVE_RETENTION_DAYS", _DEFAULT_ARCHIVE_RETENTION_DAYS
58
+ )
59
+ )
60
+ except (TypeError, ValueError):
61
+ return _DEFAULT_ARCHIVE_RETENTION_DAYS
62
+
63
+
64
+ def _archive_cutoff(now: float | None = None) -> float | None:
65
+ days = _archive_retention_days()
66
+ if days <= 0:
67
+ return None
68
+ return (time.time() if now is None else now) - days * 86400.0
69
+
70
+
71
+ def _columns(conn: sqlite3.Connection, table: str) -> list[tuple[str, str]]:
72
+ """Return ``[(name, type), ...]`` for *table* (empty if it doesn't exist)."""
73
+ return [
74
+ (r[1], r[2] or "TEXT")
75
+ for r in conn.execute(f"PRAGMA table_info({table})").fetchall() # noqa: S608
76
+ ]
77
+
78
+
79
+ def _ensure_archive_mirror(conn: sqlite3.Connection, src: str, dst: str) -> list[str]:
80
+ """Create/extend *dst* so it has every column of *src* (no constraints — the
81
+ archive is dumb storage). Robust to future ``ALTER TABLE`` on *src*.
82
+
83
+ Returns the shared column-name list to use for the copy.
84
+ """
85
+ src_cols = _columns(conn, src)
86
+ dst_existing = {name for name, _ in _columns(conn, dst)}
87
+ if not dst_existing:
88
+ coldefs = ", ".join(f'"{name}" {ctype}' for name, ctype in src_cols)
89
+ conn.execute(f"CREATE TABLE {dst} ({coldefs})") # noqa: S608
90
+ else:
91
+ for name, ctype in src_cols:
92
+ if name not in dst_existing:
93
+ conn.execute(f'ALTER TABLE {dst} ADD COLUMN "{name}" {ctype}') # noqa: S608
94
+ return [name for name, _ in src_cols]
95
+
96
+
97
+ def _move_rows(
98
+ conn: sqlite3.Connection,
99
+ src: str,
100
+ dst: str,
101
+ key_col: str,
102
+ ids: list[str],
103
+ ) -> None:
104
+ """Copy then delete rows of *src* whose *key_col* is in *ids* (batched)."""
105
+ cols = _ensure_archive_mirror(conn, src, dst)
106
+ collist = ", ".join(f'"{c}"' for c in cols)
107
+ for i in range(0, len(ids), _BATCH):
108
+ batch = ids[i : i + _BATCH]
109
+ placeholders = ",".join("?" for _ in batch)
110
+ conn.execute(
111
+ f"INSERT INTO {dst} ({collist}) SELECT {collist} FROM {src} " # noqa: S608
112
+ f"WHERE {key_col} IN ({placeholders})",
113
+ batch,
114
+ )
115
+ conn.execute(
116
+ f"DELETE FROM {src} WHERE {key_col} IN ({placeholders})", # noqa: S608
117
+ batch,
118
+ )
119
+
120
+
121
+ def sweep(*, dry_run: bool = False, now: float | None = None) -> dict:
122
+ """Archive stale terminal assignments + their notifications.
123
+
124
+ Returns ``{"archived_assignments": N, "archived_notifications": M,
125
+ "dry_run": bool, "retention_days": D}``. ``archived_*`` are the counts that
126
+ were (or, for ``dry_run``, would be) moved. A no-op returns zeros.
127
+
128
+ Conservative by construction: nothing active, recent (within the archive
129
+ window), queued-for-merge, latest-of-an-open-issue, or review-linked to any
130
+ such row is ever moved.
131
+ """
132
+ cutoff = _archive_cutoff(now)
133
+ result = {
134
+ "archived_assignments": 0,
135
+ "archived_notifications": 0,
136
+ "dry_run": dry_run,
137
+ "retention_days": _archive_retention_days(),
138
+ }
139
+ if cutoff is None:
140
+ return result # archiving disabled
141
+
142
+ conn = get_connection()
143
+ index = [
144
+ dict(r)
145
+ for r in conn.execute(
146
+ f"SELECT {_KEEP_INDEX_COLUMNS} FROM {_ASSIGNMENTS}" # noqa: S608
147
+ ).fetchall()
148
+ ]
149
+ mq_ids = {
150
+ r["assignment_id"]
151
+ for r in conn.execute(
152
+ f"SELECT assignment_id FROM merge_queue" # noqa: S608
153
+ ).fetchall()
154
+ if r["assignment_id"]
155
+ }
156
+ open_keys = {
157
+ (r["repo_name"], r["number"])
158
+ for r in conn.execute(
159
+ "SELECT repo_name, number FROM issues WHERE LOWER(state) != 'closed'"
160
+ ).fetchall()
161
+ }
162
+ protected = compute_board_keep_ids(index, mq_ids, open_keys, cutoff)
163
+
164
+ candidates = [
165
+ r["assignment_id"]
166
+ for r in index
167
+ if r["assignment_id"]
168
+ and r["assignment_id"] not in protected
169
+ and (r["status"] or "").lower() in TERMINAL_STATUSES
170
+ ]
171
+ candidate_set = set(candidates)
172
+
173
+ # Notifications to archive: those belonging to an archived assignment, plus
174
+ # old notifications not referencing a still-protected assignment.
175
+ notif_ids = [
176
+ r["assignment_id"]
177
+ for r in conn.execute(
178
+ f"SELECT assignment_id, posted_at FROM {_NOTIFICATIONS}" # noqa: S608
179
+ ).fetchall()
180
+ if r["assignment_id"]
181
+ and (
182
+ r["assignment_id"] in candidate_set
183
+ or (
184
+ (r["posted_at"] is not None and r["posted_at"] < cutoff)
185
+ and r["assignment_id"] not in protected
186
+ )
187
+ )
188
+ ]
189
+
190
+ result["archived_assignments"] = len(candidates)
191
+ result["archived_notifications"] = len(notif_ids)
192
+ if dry_run or (not candidates and not notif_ids):
193
+ return result
194
+
195
+ with conn:
196
+ if notif_ids:
197
+ _move_rows(
198
+ conn, _NOTIFICATIONS, _NOTIFICATIONS_ARCHIVE, "assignment_id", notif_ids
199
+ )
200
+ if candidates:
201
+ _move_rows(
202
+ conn, _ASSIGNMENTS, _ASSIGNMENTS_ARCHIVE, "assignment_id", candidates
203
+ )
204
+ return result