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
coord/client.py ADDED
@@ -0,0 +1,610 @@
1
+ """Thin-client access to a remote ``coord serve`` daemon (#584/#594).
2
+
3
+ When a ``board_service`` is configured, the ``coord`` CLI (and, in spirit, any
4
+ Python consumer) reads the board + config from the daemon over Tailscale instead
5
+ of opening a local ``~/.coord/coord.db`` / ``coordinator.yml``. When it is NOT
6
+ configured, callers fall back to the existing local-SQLite path — byte-identical
7
+ behaviour, no regression.
8
+
9
+ Bootstrap contract (kubeconfig-style), resolution order **flag > env > file**:
10
+
11
+ * ``--service`` / ``--token`` flags (passed by the caller),
12
+ * ``COORD_SERVICE_URL`` / ``COORD_TOKEN`` environment variables,
13
+ * ``~/.coord/client.toml`` with ``board_service = "http://host:7435"`` and an
14
+ optional ``token = "..."``.
15
+
16
+ The client carries **no** Claude or gh credentials — it only reads the board and
17
+ config.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import tomllib
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import httpx
29
+
30
+ from coord._board_mapping import assemble_board, infer_review_state
31
+ from coord.models import Board
32
+
33
+ COORD_DIR = Path.home() / ".coord"
34
+ CLIENT_TOML = COORD_DIR / "client.toml"
35
+ # Local cache of the daemon-served coordinator.yml so the existing
36
+ # coord.config.load() parser can consume it unchanged (config.py has no dict
37
+ # round-trip — raw YAML is the lossless contract).
38
+ REMOTE_CONFIG_CACHE = COORD_DIR / "coordinator.remote.yml"
39
+
40
+ _DEFAULT_TIMEOUT = 5.0
41
+ # Writes can post a GitHub comment synchronously on the daemon, so give them a
42
+ # longer ceiling than read GETs.
43
+ _WRITE_TIMEOUT = 30.0
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ServiceConfig:
48
+ url: str
49
+ token: str | None = None
50
+
51
+
52
+ def resolve_board_service(
53
+ flag_url: str | None = None,
54
+ flag_token: str | None = None,
55
+ ) -> ServiceConfig | None:
56
+ """Resolve the board service per the bootstrap contract, or ``None`` if unset."""
57
+ url = flag_url or os.environ.get("COORD_SERVICE_URL")
58
+ token = flag_token or os.environ.get("COORD_TOKEN")
59
+ if not url and CLIENT_TOML.exists():
60
+ try:
61
+ data = tomllib.loads(CLIENT_TOML.read_text())
62
+ except (tomllib.TOMLDecodeError, OSError):
63
+ data = {}
64
+ url = data.get("board_service")
65
+ token = token or data.get("token")
66
+ if not url:
67
+ return None
68
+ return ServiceConfig(url=url.rstrip("/"), token=token)
69
+
70
+
71
+ def _headers(svc: ServiceConfig) -> dict[str, str]:
72
+ return {"Authorization": f"Bearer {svc.token}"} if svc.token else {}
73
+
74
+
75
+ def fetch_board_payload(svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT) -> dict:
76
+ """GET /board → the raw projection dict (also carries machines/merge_queue/…)."""
77
+ resp = httpx.get(f"{svc.url}/board", headers=_headers(svc), timeout=timeout)
78
+ resp.raise_for_status()
79
+ return resp.json()
80
+
81
+
82
+ def fetch_assignment(
83
+ svc: ServiceConfig, assignment_id: str, *, timeout: float = _DEFAULT_TIMEOUT
84
+ ) -> dict | None:
85
+ """GET /assignment/{id} → the full single-assignment row (#1336/#1337).
86
+
87
+ The point lookup for the fields the ``/board`` collection bounds or omits
88
+ (``briefing``, full ``review_findings``/``test_plan``/``test_reason``/…).
89
+ Returns ``None`` on 404 — either the id is genuinely unknown, or the
90
+ daemon predates the endpoint (an unmatched route is also a 404); callers
91
+ that must distinguish fall back to :func:`fetch_board_payload`.
92
+ Raises ``httpx.HTTPError`` on transport failure / other HTTP errors.
93
+ """
94
+ resp = httpx.get(
95
+ f"{svc.url}/assignment/{assignment_id}",
96
+ headers=_headers(svc),
97
+ timeout=timeout,
98
+ )
99
+ if resp.status_code == 404:
100
+ return None
101
+ resp.raise_for_status()
102
+ return resp.json()
103
+
104
+
105
+ def fetch_issue(
106
+ svc: ServiceConfig,
107
+ repo_name: str,
108
+ number: int,
109
+ *,
110
+ timeout: float = _DEFAULT_TIMEOUT,
111
+ ) -> dict | None:
112
+ """GET /issue/{repo_name}/{number} → the full single-issue row (#1337).
113
+
114
+ Same contract as :func:`fetch_assignment`: ``None`` on 404 (unknown issue
115
+ OR pre-#1337 daemon), ``httpx.HTTPError`` on transport/HTTP failure.
116
+ """
117
+ resp = httpx.get(
118
+ f"{svc.url}/issue/{repo_name}/{number}",
119
+ headers=_headers(svc),
120
+ timeout=timeout,
121
+ )
122
+ if resp.status_code == 404:
123
+ return None
124
+ resp.raise_for_status()
125
+ return resp.json()
126
+
127
+
128
+ def board_from_payload(payload: dict) -> Board:
129
+ """Reconstruct a :class:`Board` from a ``/board`` payload.
130
+
131
+ Delegates row→Board assembly to ``coord._board_mapping.assemble_board`` —
132
+ the same core ``coord.state._query_board`` uses — so the remote board
133
+ can't drift from a locally-built one (#749: the dual state.py/client.py
134
+ projection is now one shared implementation).
135
+ """
136
+ assignments_raw = payload.get("assignments", [])
137
+ plans = payload.get("plans") or {}
138
+ board = assemble_board(assignments_raw, plans, payload.get("round_number") or 0)
139
+ review_rows = [
140
+ {
141
+ "assignment_id": d.get("assignment_id"),
142
+ "review_of_assignment_id": d.get("review_of_assignment_id"),
143
+ "status": d.get("status"),
144
+ }
145
+ for d in assignments_raw
146
+ if d.get("type") == "review" and d.get("review_of_assignment_id")
147
+ ]
148
+ notified_ids = {n.get("assignment_id") for n in payload.get("notifications", [])}
149
+ infer_review_state(board, review_rows, notified_ids)
150
+ return board
151
+
152
+
153
+ def fetch_remote_board(svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT) -> Board:
154
+ """GET /board and reconstruct a :class:`Board`."""
155
+ return board_from_payload(fetch_board_payload(svc, timeout=timeout))
156
+
157
+
158
+ def serialize_board(board: Board) -> dict:
159
+ """Serialize *board* for ``POST /board`` — the inverse of :func:`board_from_payload`.
160
+
161
+ Only ships what ``coord.state.save_board`` actually persists: assignment
162
+ rows (``dataclasses.asdict``, so JSON columns are native lists/dicts —
163
+ tolerated by ``row_to_assignment`` on the way back in) + ``round_number``.
164
+ ``save_board`` is upsert-only (never deletes), so POSTing a client's full
165
+ in-memory board is a safe, non-lossy drop-in for what today runs directly
166
+ against the local DB (#749).
167
+ """
168
+ from dataclasses import asdict # noqa: PLC0415
169
+
170
+ return {
171
+ "assignments": [asdict(a) for a in board.active + board.completed],
172
+ "round_number": board.round_number,
173
+ }
174
+
175
+
176
+ def post_board(svc: ServiceConfig, board: Board, *, timeout: float = _WRITE_TIMEOUT) -> None:
177
+ """POST the full board to the daemon's generic upsert endpoint (#749).
178
+
179
+ Backs ``coord.board_service.write_board`` for the commands that still
180
+ read-modify-write the whole board locally (``assign``/``approve``/``stop``/
181
+ ``retry``/``resume``/``bounce``/``done``/``pr``/… and the dashboard/auto_loop
182
+ call sites) — replacing a local ``save_board`` that used to silently no-op
183
+ on a thin client.
184
+ """
185
+ post_record(svc, "/board", serialize_board(board), timeout=timeout)
186
+
187
+
188
+ def post_record(
189
+ svc: ServiceConfig,
190
+ path: str,
191
+ payload: dict,
192
+ *,
193
+ timeout: float = _WRITE_TIMEOUT,
194
+ ) -> dict:
195
+ """POST a serialized seam record to the daemon and return the JSON outcome.
196
+
197
+ Used by the ``coord.issue_store`` seam (#590): when ``board_service`` is
198
+ set, ``post_result`` / ``post_completion`` POST the record here instead of
199
+ writing the local DB, so a thin client's result lands on the one shared DB.
200
+ Raises ``httpx.HTTPError`` on transport/HTTP failure — the seam decides
201
+ whether that is fatal (``post_result``) or best-effort (``post_completion``).
202
+ """
203
+ resp = httpx.post(
204
+ f"{svc.url}{path}", json=payload, headers=_headers(svc), timeout=timeout
205
+ )
206
+ resp.raise_for_status()
207
+ return resp.json()
208
+
209
+
210
+ def fetch_paused_machines(
211
+ svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT
212
+ ) -> set[str]:
213
+ """GET /pause → the daemon's current paused-machine set (#1563).
214
+
215
+ Raises ``httpx.HTTPError`` on transport/HTTP failure. This is the read
216
+ side of the thin-client pause fix: `coord.machine_pause.paused_set()`
217
+ catches the error itself and degrades to "nothing is paused" (fail-soft,
218
+ matching every other daemon read-through helper here) — this function
219
+ stays strict so a caller that DOES want to distinguish "confirmed empty"
220
+ from "couldn't ask" still can.
221
+ """
222
+ resp = httpx.get(f"{svc.url}/pause", headers=_headers(svc), timeout=timeout)
223
+ resp.raise_for_status()
224
+ data = resp.json()
225
+ items = data.get("paused") if isinstance(data, dict) else None
226
+ return {str(x) for x in items if isinstance(x, str) and x} if isinstance(items, list) else set()
227
+
228
+
229
+ def fetch_cordons(
230
+ svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT
231
+ ) -> list[dict]:
232
+ """GET /pause → the daemon's release-cordon records (#2101).
233
+
234
+ Returns the raw record dicts (``machine``/``owner``/``reason``/
235
+ ``target_version``/``created_at``/``expires_at``) so a caller can decide
236
+ expiry and drain deadlines for itself — the whole point of #2101 trap B is
237
+ that a cordon carries its own expiry rather than depending on some process
238
+ remembering to clean it up.
239
+
240
+ ``[]`` when the daemon predates #2101 (the key is simply absent), which is
241
+ the right read: a daemon with no cordon store has no cordons. Raises
242
+ ``httpx.HTTPError`` on transport/HTTP failure — `coord.machine_pause.
243
+ cordons()` catches that and degrades to "nothing is cordoned", same
244
+ fail-soft read posture as `fetch_paused_machines`.
245
+ """
246
+ resp = httpx.get(f"{svc.url}/pause", headers=_headers(svc), timeout=timeout)
247
+ resp.raise_for_status()
248
+ data = resp.json()
249
+ rows = data.get("cordons") if isinstance(data, dict) else None
250
+ return [dict(r) for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
251
+
252
+
253
+ def post_cordon(
254
+ svc: ServiceConfig,
255
+ machine: str,
256
+ action: str,
257
+ *,
258
+ reason: str = "",
259
+ target_version: str | None = None,
260
+ ttl_seconds: float | None = None,
261
+ timeout: float = _WRITE_TIMEOUT,
262
+ ) -> dict:
263
+ """POST /pause {machine, action: cordon|uncordon, ...} (#2101).
264
+
265
+ Deliberately the SAME endpoint as pause/unpause rather than a new one: a
266
+ cordon is the same routing decision with a different owner, and one
267
+ endpoint means one place a thin client can be wrong about it.
268
+
269
+ Raises ``httpx.HTTPError`` on transport/HTTP failure — a cordon that
270
+ silently fails to reach the daemon would let `coord release propagate`
271
+ restart agents believing it had stopped new work. A daemon that predates
272
+ #2101 answers 400 ``unknown action``, which surfaces here as an
273
+ ``HTTPStatusError`` rather than a silent no-op — deploy the daemon first
274
+ (the same server-first ordering every other endpoint here needs).
275
+ """
276
+ payload: dict = {"machine": machine, "action": action}
277
+ if reason:
278
+ payload["reason"] = reason
279
+ if target_version:
280
+ payload["target_version"] = target_version
281
+ if ttl_seconds is not None:
282
+ payload["ttl_seconds"] = float(ttl_seconds)
283
+ return post_record(svc, "/pause", payload, timeout=timeout)
284
+
285
+
286
+ def post_pause(
287
+ svc: ServiceConfig, machine: str, action: str, *, timeout: float = _WRITE_TIMEOUT
288
+ ) -> dict:
289
+ """POST /pause {machine, action} → {"paused": [...], "changed": bool} (#1563).
290
+
291
+ *action* is ``"pause"`` or ``"unpause"``. Raises ``httpx.HTTPError`` on
292
+ transport/HTTP failure — deliberately NOT fail-soft: `coord pause` on a
293
+ thin client that can't reach the daemon must fail loudly, not print
294
+ "paused: X" while the daemon never hears about it (the root cause of
295
+ #1563).
296
+ """
297
+ return post_record(svc, "/pause", {"machine": machine, "action": action}, timeout=timeout)
298
+
299
+
300
+ def fetch_remote_config(svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT) -> Path:
301
+ """GET /config, cache it to ``~/.coord/coordinator.remote.yml``, return the path.
302
+
303
+ The caller feeds the returned path to ``coord.config.load()``.
304
+ """
305
+ resp = httpx.get(f"{svc.url}/config", headers=_headers(svc), timeout=timeout)
306
+ resp.raise_for_status()
307
+ COORD_DIR.mkdir(parents=True, exist_ok=True)
308
+ REMOTE_CONFIG_CACHE.write_text(resp.text)
309
+ return REMOTE_CONFIG_CACHE
310
+
311
+
312
+ def fetch_issue_context(
313
+ svc: ServiceConfig,
314
+ repo_name: str,
315
+ issue_number: int,
316
+ *,
317
+ timeout: float = _DEFAULT_TIMEOUT,
318
+ ) -> list[dict]:
319
+ """GET an issue's raw context entries from the daemon (#603).
320
+
321
+ Returns the ``entries`` list (oldest-first); ``[]`` on ANY failure (404 from
322
+ an older daemon, network error, bad JSON). Fail-soft on purpose: this rides
323
+ the briefing read-path, so a missing/old daemon must degrade to "no context
324
+ block", never crash a dispatch.
325
+ """
326
+ try:
327
+ resp = httpx.get(
328
+ f"{svc.url}/issue-context",
329
+ params={"repo_name": repo_name, "issue_number": issue_number},
330
+ headers=_headers(svc),
331
+ timeout=timeout,
332
+ )
333
+ resp.raise_for_status()
334
+ data = resp.json()
335
+ except Exception: # noqa: BLE001 — fail-soft; briefing just omits the block
336
+ return []
337
+ entries = data.get("entries") if isinstance(data, dict) else None
338
+ return entries if isinstance(entries, list) else []
339
+
340
+
341
+ def fetch_drive_escalation(
342
+ svc: ServiceConfig,
343
+ repo_name: str,
344
+ issue_number: int,
345
+ *,
346
+ timeout: float = _DEFAULT_TIMEOUT,
347
+ ) -> dict | None:
348
+ """GET the (at most one) open driver-escalation record for an issue (#1505).
349
+
350
+ Returns ``None`` on ANY failure (404 from an older daemon, network error,
351
+ bad JSON, or genuinely no record) — ``coord escalate run``/``dismiss`` on a
352
+ thin client treat that as "nothing on file", same as the local-DB path.
353
+ """
354
+ try:
355
+ resp = httpx.get(
356
+ f"{svc.url}/drive-escalations",
357
+ params={"repo_name": repo_name, "issue_number": issue_number},
358
+ headers=_headers(svc),
359
+ timeout=timeout,
360
+ )
361
+ resp.raise_for_status()
362
+ data = resp.json()
363
+ except Exception: # noqa: BLE001 — fail-soft, mirrors fetch_issue_context
364
+ return None
365
+ entries = data.get("entries") if isinstance(data, dict) else None
366
+ if isinstance(entries, list) and entries:
367
+ return entries[0]
368
+ return None
369
+
370
+
371
+ def fetch_drive_escalations(
372
+ svc: ServiceConfig,
373
+ repo_name: str | None = None,
374
+ *,
375
+ timeout: float = _DEFAULT_TIMEOUT,
376
+ ) -> list[dict]:
377
+ """GET every open driver-escalation record, optionally filtered to one
378
+ repo (#1505). ``[]`` on ANY failure — fail-soft, mirrors
379
+ :func:`fetch_issue_context`."""
380
+ try:
381
+ params = {"repo_name": repo_name} if repo_name else {}
382
+ resp = httpx.get(
383
+ f"{svc.url}/drive-escalations",
384
+ params=params,
385
+ headers=_headers(svc),
386
+ timeout=timeout,
387
+ )
388
+ resp.raise_for_status()
389
+ data = resp.json()
390
+ except Exception: # noqa: BLE001
391
+ return []
392
+ entries = data.get("entries") if isinstance(data, dict) else None
393
+ return entries if isinstance(entries, list) else []
394
+
395
+
396
+ def fetch_drive_queue(
397
+ svc: ServiceConfig,
398
+ repo_name: str | None = None,
399
+ *,
400
+ timeout: float = _DEFAULT_TIMEOUT,
401
+ ) -> list[dict]:
402
+ """GET the drive queue in run order, optionally filtered to one repo (#1753).
403
+
404
+ ``[]`` on ANY failure (404 from a daemon predating this route, network
405
+ error, bad JSON, or a genuinely empty queue) — fail-soft, mirrors
406
+ :func:`fetch_drive_escalations`. ``after_json`` arrives already decoded to
407
+ a list; this helper does not re-parse it.
408
+ """
409
+ try:
410
+ params = {"repo_name": repo_name} if repo_name else {}
411
+ resp = httpx.get(
412
+ f"{svc.url}/drive-queue",
413
+ params=params,
414
+ headers=_headers(svc),
415
+ timeout=timeout,
416
+ )
417
+ resp.raise_for_status()
418
+ data = resp.json()
419
+ except Exception: # noqa: BLE001
420
+ return []
421
+ entries = data.get("entries") if isinstance(data, dict) else None
422
+ return entries if isinstance(entries, list) else []
423
+
424
+
425
+ def fetch_drive_queue_entry(
426
+ svc: ServiceConfig,
427
+ repo_name: str,
428
+ issue_number: int,
429
+ *,
430
+ timeout: float = _DEFAULT_TIMEOUT,
431
+ ) -> dict | None:
432
+ """GET the (at most one) drive-queue entry for an issue (#1753).
433
+
434
+ ``None`` on ANY failure or when the issue is not queued — fail-soft,
435
+ mirrors :func:`fetch_drive_escalation`.
436
+ """
437
+ try:
438
+ resp = httpx.get(
439
+ f"{svc.url}/drive-queue",
440
+ params={"repo_name": repo_name, "issue_number": issue_number},
441
+ headers=_headers(svc),
442
+ timeout=timeout,
443
+ )
444
+ resp.raise_for_status()
445
+ data = resp.json()
446
+ except Exception: # noqa: BLE001
447
+ return None
448
+ entries = data.get("entries") if isinstance(data, dict) else None
449
+ if isinstance(entries, list) and entries:
450
+ return entries[0]
451
+ return None
452
+
453
+
454
+ def fetch_gate_a_approval(
455
+ svc: ServiceConfig,
456
+ repo_name: str,
457
+ milestone_number: int,
458
+ *,
459
+ timeout: float = _DEFAULT_TIMEOUT,
460
+ ) -> dict | None:
461
+ """GET one milestone's Gate-A human sign-off record (#2063).
462
+
463
+ Fail-soft to ``None`` — the opposite posture to
464
+ :func:`fetch_milestone_gate` above, and deliberately so. There, "couldn't
465
+ ask" collapsing to ``None`` would let a thin client silently race a gate
466
+ tick, so it raises. Here ``None`` means "no approval recorded", which
467
+ makes the only consumer
468
+ (:func:`coord.milestone_dispatch.issue_oracle_ready`) **refuse** — an
469
+ unreachable daemon fails closed, with a refusal an operator can read and
470
+ act on, rather than a traceback out of an unrelated dispatch path.
471
+ """
472
+ try:
473
+ resp = httpx.get(
474
+ f"{svc.url}/gate-a-approval",
475
+ params={"repo_name": repo_name, "milestone_number": milestone_number},
476
+ headers=_headers(svc),
477
+ timeout=timeout,
478
+ )
479
+ resp.raise_for_status()
480
+ data = resp.json()
481
+ except (httpx.HTTPError, ValueError):
482
+ return None
483
+ entries = data.get("entries") if isinstance(data, dict) else None
484
+ if isinstance(entries, list) and entries:
485
+ first = entries[0]
486
+ return first if isinstance(first, dict) else None
487
+ return None
488
+
489
+
490
+ def fetch_milestone_gate(
491
+ svc: ServiceConfig,
492
+ repo_name: str,
493
+ tracking_issue: int,
494
+ *,
495
+ timeout: float = _DEFAULT_TIMEOUT,
496
+ ) -> dict | None:
497
+ """GET one milestone's gate record from the daemon (#1930, epic #1440).
498
+
499
+ Deliberately does **not** follow the fail-soft-to-``None`` convention
500
+ the other ``fetch_*`` helpers in this module use. The one caller that
501
+ matters, ``coord milestone dispatch``'s exactly-one-overseer guard, must
502
+ be able to tell "confirmed not gate-controlled" apart from "couldn't
503
+ ask the daemon" — collapsing both to ``None`` would let a thin client
504
+ that can't reach the daemon silently race a gate tick it has no way to
505
+ see, which is exactly the bug #1930 exists to close. Raises
506
+ ``httpx.HTTPError`` on a network/HTTP failure and ``ValueError`` on a
507
+ malformed body; callers should treat either as "unknown — refuse rather
508
+ than risk a race", not swallow it.
509
+ """
510
+ resp = httpx.get(
511
+ f"{svc.url}/milestone-gate",
512
+ params={"repo_name": repo_name, "tracking_issue": tracking_issue},
513
+ headers=_headers(svc),
514
+ timeout=timeout,
515
+ )
516
+ resp.raise_for_status()
517
+ data = resp.json()
518
+ entries = data.get("entries") if isinstance(data, dict) else None
519
+ if not isinstance(entries, list):
520
+ raise ValueError("malformed /milestone-gate response: no 'entries' list")
521
+ return entries[0] if entries else None
522
+
523
+
524
+ def fetch_audit_log(
525
+ svc: ServiceConfig,
526
+ *,
527
+ since: float | None = None,
528
+ until: float | None = None,
529
+ event_type: str | None = None,
530
+ category: str | None = None,
531
+ repo: str | None = None,
532
+ issue: int | None = None,
533
+ assignment_id: str | None = None,
534
+ tier: str | None = None,
535
+ limit: int = 200,
536
+ cursor: str | None = None,
537
+ timeout: float = _DEFAULT_TIMEOUT,
538
+ ) -> dict:
539
+ """GET the paginated audit log from the daemon (#1037).
540
+
541
+ Unlike :func:`fetch_issue_context`, this does NOT fail-soft: ``coord
542
+ audit`` is an explicit read the user asked for, so a transport/HTTP
543
+ error (including a 404 from a daemon that predates this endpoint —
544
+ see the endpoint-deploy-ordering rule) raises ``httpx.HTTPError`` for
545
+ the CLI to report plainly instead of silently rendering an empty log.
546
+ """
547
+ params: dict[str, Any] = {"limit": limit}
548
+ for key, value in (
549
+ ("since", since), ("until", until), ("type", event_type),
550
+ ("category", category), ("repo", repo), ("issue", issue),
551
+ ("assignment", assignment_id), ("tier", tier), ("cursor", cursor),
552
+ ):
553
+ if value is not None:
554
+ params[key] = value
555
+ resp = httpx.get(
556
+ f"{svc.url}/audit", params=params, headers=_headers(svc), timeout=timeout
557
+ )
558
+ resp.raise_for_status()
559
+ return resp.json()
560
+
561
+
562
+ # #1742: the report engine lives on the daemon (the audit trail it folds
563
+ # does), so a thin client fetches both the catalogue and the rendered result
564
+ # over HTTP. Like fetch_audit_log, these do NOT fail-soft — `coord report` is
565
+ # an explicit ask, and a 404 from a daemon predating these routes must read
566
+ # as "restart coord-serve", not as "no reports".
567
+ _REPORT_TIMEOUT = 30.0
568
+
569
+
570
+ def fetch_report_catalogue(
571
+ svc: ServiceConfig, *, timeout: float = _DEFAULT_TIMEOUT
572
+ ) -> dict:
573
+ """GET the report catalogue (``{"reports": [...]}``) from the daemon."""
574
+ resp = httpx.get(f"{svc.url}/report", headers=_headers(svc), timeout=timeout)
575
+ resp.raise_for_status()
576
+ return resp.json()
577
+
578
+
579
+ def fetch_report(
580
+ svc: ServiceConfig,
581
+ report_id: str,
582
+ params: dict[str, Any] | None = None,
583
+ *,
584
+ timeout: float = _REPORT_TIMEOUT,
585
+ ) -> dict:
586
+ """GET a rendered ``ReportResult`` from the daemon.
587
+
588
+ Timeout is deliberately far above ``_DEFAULT_TIMEOUT``: a wide window
589
+ walks several 500-row audit pages server-side, which is slower than any
590
+ point read but still bounded.
591
+ """
592
+ resp = httpx.get(
593
+ f"{svc.url}/report/{report_id}",
594
+ params={k: v for k, v in (params or {}).items() if v not in (None, "")},
595
+ headers=_headers(svc),
596
+ timeout=timeout,
597
+ )
598
+ if resp.status_code in (400, 404):
599
+ # A bad param / unknown report is a user error with a message the
600
+ # daemon already worded — surface it as a plain error, not as an
601
+ # httpx status traceback.
602
+ detail = ""
603
+ try:
604
+ body = resp.json()
605
+ detail = body.get("error") or body.get("detail") or ""
606
+ except Exception: # noqa: BLE001 — non-JSON error body; fall through
607
+ detail = resp.text.strip()
608
+ raise ValueError(detail or f"report request rejected ({resp.status_code})")
609
+ resp.raise_for_status()
610
+ return resp.json()
@@ -0,0 +1 @@
1
+ """coord CLI command modules (#747 cli.py split)."""