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,341 @@
1
+ """`coord report` — CLI surface over the report engine (#1742).
2
+
3
+ Sibling of ``coord audit``: same ``--json`` convention, same
4
+ ``coord.state`` seam discipline (``list_reports`` / ``run_report`` route to
5
+ the daemon when ``board_service`` is set, local registry otherwise), so a
6
+ thin client never opens ``~/.coord/coord.db`` and never folds the audit
7
+ trail itself.
8
+
9
+ ``coord report list`` prints the catalogue with each report's parameters,
10
+ their allowed values and their defaults. ``coord report run <id>`` renders
11
+ one report as a plain table plus its notes block; ``--format json`` emits
12
+ the ``ReportResult`` verbatim — the same bytes the daemon's
13
+ ``GET /report/{id}`` returns for the same window.
14
+
15
+ ``--format csv`` (#1765) writes the machine-readable form to stdout, so it
16
+ pipes and redirects normally. It calls :func:`coord.reports.result_to_csv`
17
+ on the **raw wire result** — the same function, on the same input, that
18
+ ``GET /report/{id}?format=csv`` calls — rather than re-serialising the human
19
+ table above. That is deliberate and load-bearing twice over: it is why the
20
+ CLI and the daemon emit identical bytes, and it is why ``started_at``
21
+ exports as an epoch instead of the ``13h ago`` that ``_format_cell``
22
+ renders. ``--json`` survives as a hidden alias for ``--format json``.
23
+
24
+ Exit codes: ``2`` for a bad request (unknown report, unknown parameter, bad
25
+ value — the message names what was allowed), ``1`` for a read/transport
26
+ failure. Never a traceback for either.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json as _json
32
+ import time
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+
36
+ import click
37
+
38
+ from coord.commands._common import _CONFIG_OPTION
39
+
40
+ # Row keys whose values are epoch timestamps — rendered as a relative age in
41
+ # the human table (absolute in --json, which is the machine contract).
42
+ # Fallback only: used when a row column is not covered by the report's
43
+ # `column_meta` (#1760) — a report that ships full metadata never reaches
44
+ # this.
45
+ _TS_COLUMNS = frozenset(
46
+ {"started_at", "merged_at", "first_event_at", "last_event_at"}
47
+ )
48
+
49
+ # Per-column display cap for the human table. Long free text (titles, drive
50
+ # exit reasons) is truncated here and never in --json.
51
+ _MAX_CELL = 44
52
+
53
+ # Shorter headers for the human table only — `columns` itself is the wire
54
+ # contract (#1741 renders against it) and is never rewritten. Fallback only
55
+ # (#1760): a report's `column_meta[].label` takes precedence when present,
56
+ # so this list only matters for a report that ships none.
57
+ _HEADER_ALIASES = {
58
+ "started_at": "started",
59
+ "merged_at": "merged",
60
+ "fix_iterations": "fixes",
61
+ "test_verdicts": "tests",
62
+ "review_verdicts": "reviews",
63
+ "drive_exit": "drive exit",
64
+ "first_event_at": "first",
65
+ "last_event_at": "last",
66
+ }
67
+
68
+
69
+ def _relative_time(ts: float | None, *, now: float | None = None) -> str:
70
+ if ts is None:
71
+ return "-"
72
+ delta = (time.time() if now is None else now) - float(ts)
73
+ if delta < 0:
74
+ return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%m-%d %H:%M")
75
+ for seconds, unit in ((60, "s"), (3600, "m"), (86400, "h"), (86400 * 30, "d")):
76
+ if delta < seconds:
77
+ prior = {60: 1, 3600: 60, 86400: 3600, 86400 * 30: 86400}[seconds]
78
+ return f"{int(delta // prior)}{unit} ago"
79
+ return f"{int(delta // (86400 * 30))}mo ago"
80
+
81
+
82
+ def _truncate(s: str, width: int) -> str:
83
+ return s if len(s) <= width else s[: max(1, width - 1)] + "…"
84
+
85
+
86
+ def _format_cell(column: str, row: dict, meta: dict | None = None) -> str:
87
+ """Render one row value for the human table.
88
+
89
+ Dispatches on the column's declared ``kind`` from ``column_meta``
90
+ (#1760) when the report supplies one — that is what lets the CLI stop
91
+ hardcoding per-field knowledge (``_TS_COLUMNS`` et al) and the panel and
92
+ the CLI format the same column the same way without drifting. Falls
93
+ back to dispatching on the value's *shape* (timestamp column by name,
94
+ list, dict, None) when no metadata is present, so a report that ships
95
+ none still gets a table.
96
+ """
97
+ value = row.get(column)
98
+ kind = (meta or {}).get("kind")
99
+ is_timestamp = kind == "timestamp" if kind else column in _TS_COLUMNS
100
+ if column == "started_at" and value is None and row.get("started_before_window"):
101
+ # Not "unknown" — "began before this window". The distinction is the
102
+ # whole point of the started_before_window flag. Scoped to
103
+ # started_at specifically: a None merged_at just means "not merged
104
+ # yet" and must not also read as "before window".
105
+ return "<window"
106
+ if value is None:
107
+ return "-"
108
+ if is_timestamp:
109
+ return _relative_time(value)
110
+ if kind == "money":
111
+ # Four decimals, matching `coord usage`'s rollup views — a leg can
112
+ # genuinely cost $0.0032 and "$0.00" would read as free.
113
+ try:
114
+ return f"${float(value):.4f}"
115
+ except (TypeError, ValueError):
116
+ return str(value)
117
+ if isinstance(value, bool):
118
+ return "yes" if value else "no"
119
+ if isinstance(value, (list, tuple)):
120
+ return ",".join(str(v) for v in value) if value else "-"
121
+ if isinstance(value, dict):
122
+ # drive_exit and friends: the two fields that matter, inline.
123
+ if "exit_code" in value:
124
+ reason = value.get("reason")
125
+ code = value.get("exit_code")
126
+ code_s = "crash" if code is None else str(code)
127
+ return f"exit {code_s}" + (f": {reason}" if reason else "")
128
+ return ",".join(f"{k}={v}" for k, v in value.items())
129
+ return str(value)
130
+
131
+
132
+ def _pad(cell: str, width: int, align: str) -> str:
133
+ return cell.rjust(width) if align == "right" else cell.ljust(width)
134
+
135
+
136
+ def _render_table(result: dict) -> list[str]:
137
+ columns = list(result.get("columns") or [])
138
+ rows = list(result.get("rows") or [])
139
+ if not columns or not rows:
140
+ return []
141
+ meta_by_id = {
142
+ m.get("id"): m for m in (result.get("column_meta") or []) if isinstance(m, dict)
143
+ }
144
+ headers = [
145
+ (meta_by_id.get(c, {}).get("label") or _HEADER_ALIASES.get(c, c)).upper()
146
+ for c in columns
147
+ ]
148
+ aligns = [meta_by_id.get(c, {}).get("align") or "left" for c in columns]
149
+ cells = [
150
+ [_truncate(_format_cell(c, r, meta_by_id.get(c)), _MAX_CELL) for c in columns]
151
+ for r in rows
152
+ ]
153
+ # #1763: an optional pinned grand-total row. Rendered through the same
154
+ # per-column formatter as any other row — the only special-casing is the
155
+ # `Σ` marker in the first column, which the wire deliberately leaves
156
+ # blank so each renderer picks its own.
157
+ totals = result.get("totals")
158
+ footer: list[str] | None = None
159
+ if isinstance(totals, dict):
160
+ footer = [
161
+ _truncate(_format_cell(c, totals, meta_by_id.get(c)), _MAX_CELL)
162
+ for c in columns
163
+ ]
164
+ if footer and footer[0] == "-":
165
+ footer[0] = "Σ"
166
+ widths = [
167
+ max(len(header), *(len(row[i]) for row in cells + ([footer] if footer else [])))
168
+ for i, header in enumerate(headers)
169
+ ]
170
+ lines = [" ".join(h.ljust(widths[i]) for i, h in enumerate(headers)).rstrip()]
171
+ for row in cells:
172
+ lines.append(
173
+ " ".join(_pad(cell, widths[i], aligns[i]) for i, cell in enumerate(row)).rstrip()
174
+ )
175
+ if footer:
176
+ lines.append(" ".join("-" * widths[i] for i in range(len(headers))).rstrip())
177
+ lines.append(
178
+ " ".join(_pad(cell, widths[i], aligns[i]) for i, cell in enumerate(footer)).rstrip()
179
+ )
180
+ return lines
181
+
182
+
183
+ def _abs_time(ts: float | None) -> str:
184
+ if ts is None:
185
+ return "?"
186
+ return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime(
187
+ "%Y-%m-%d %H:%M:%SZ"
188
+ )
189
+
190
+
191
+ @click.group("report", help="Run reports over the coordinator's own history (#1742).")
192
+ def report_group() -> None:
193
+ """Report engine — a small registry of named, parameterised reports."""
194
+
195
+
196
+ @report_group.command("list", help="List the available reports and their parameters.")
197
+ @click.option("--json", "output_json", is_flag=True, default=False, help="Output the raw catalogue JSON.")
198
+ @_CONFIG_OPTION
199
+ def report_list(
200
+ output_json: bool,
201
+ config_path: Path, # noqa: ARG001 — accepted for --config-flag consistency; reports need no coordinator.yml
202
+ ) -> None:
203
+ from coord.state import list_reports # noqa: PLC0415
204
+
205
+ try:
206
+ cat = list_reports()
207
+ except Exception as e: # noqa: BLE001 — a clean CLI error, not a traceback
208
+ click.echo(f"error: report catalogue read failed: {e}", err=True)
209
+ raise SystemExit(1) from e
210
+
211
+ if output_json:
212
+ click.echo(_json.dumps(cat, indent=2, default=str))
213
+ return
214
+
215
+ reports = cat.get("reports") or []
216
+ if not reports:
217
+ click.echo("(no reports available)")
218
+ return
219
+ for i, rep in enumerate(reports):
220
+ if i:
221
+ click.echo("")
222
+ click.echo(f"{rep.get('id')} — {rep.get('title')}")
223
+ desc = (rep.get("description") or "").strip()
224
+ if desc:
225
+ click.echo(f" {desc}")
226
+ params = rep.get("params") or []
227
+ if not params:
228
+ click.echo(" params: (none)")
229
+ continue
230
+ click.echo(" params:")
231
+ for p in params:
232
+ default = p.get("default")
233
+ default_s = repr(default) if default else "(none)"
234
+ bits = [f"kind={p.get('kind')}", f"default={default_s}"]
235
+ choices = p.get("choices") or []
236
+ if choices:
237
+ allowed = ", ".join(str(c) for c in choices)
238
+ if p.get("free_form"):
239
+ allowed += ", or any duration (e.g. 13h)"
240
+ bits.append(f"allowed={allowed}")
241
+ click.echo(f" {p.get('id')} ({p.get('label')})")
242
+ click.echo(f" {'; '.join(bits)}")
243
+ help_text = (p.get("help") or "").strip()
244
+ if help_text:
245
+ click.echo(f" {help_text}")
246
+
247
+
248
+ @report_group.command("run", help="Run a report by id.")
249
+ @click.argument("report_id")
250
+ @click.option(
251
+ "--param", "raw_params", multiple=True, metavar="KEY=VALUE",
252
+ help="Report parameter, repeatable (e.g. --param since=13h).",
253
+ )
254
+ @click.option(
255
+ "--format", "output_format",
256
+ type=click.Choice(["table", "json", "csv"]),
257
+ default="table",
258
+ show_default=True,
259
+ help="Output encoding: human table, raw ReportResult JSON, or CSV.",
260
+ )
261
+ @click.option(
262
+ "--json", "legacy_json", is_flag=True, default=False, hidden=True,
263
+ help="Deprecated alias for --format json.",
264
+ )
265
+ @_CONFIG_OPTION
266
+ def report_run(
267
+ report_id: str,
268
+ raw_params: tuple[str, ...],
269
+ output_format: str,
270
+ legacy_json: bool,
271
+ config_path: Path, # noqa: ARG001 — accepted for --config-flag consistency; reports need no coordinator.yml
272
+ ) -> None:
273
+ # #1765: `--json` predates `--format` and is kept as a hidden alias so
274
+ # existing scripts and the #1742 smoke commands keep working verbatim.
275
+ # Hidden, not removed: it is absent from --help (there is one documented
276
+ # way to ask for JSON) while still being accepted.
277
+ if legacy_json:
278
+ output_format = "json"
279
+ from coord.state import run_report # noqa: PLC0415
280
+
281
+ params: dict[str, str] = {}
282
+ for raw in raw_params:
283
+ if "=" not in raw:
284
+ click.echo(
285
+ f"error: --param expects KEY=VALUE, got {raw!r} "
286
+ "(e.g. --param since=13h)",
287
+ err=True,
288
+ )
289
+ raise SystemExit(2)
290
+ key, _, value = raw.partition("=")
291
+ params[key.strip()] = value
292
+
293
+ try:
294
+ result = run_report(report_id, params)
295
+ except ValueError as e:
296
+ # ReportError (local) or the daemon's 400/404 body, re-raised as a
297
+ # ValueError by coord.client.fetch_report — either way it is a user
298
+ # error whose message already names what was allowed.
299
+ click.echo(f"error: {e}", err=True)
300
+ raise SystemExit(2) from e
301
+ except Exception as e: # noqa: BLE001 — transport/DB failure
302
+ click.echo(f"error: report run failed: {e}", err=True)
303
+ raise SystemExit(1) from e
304
+
305
+ if output_format == "json":
306
+ click.echo(_json.dumps(result, indent=2, default=str))
307
+ return
308
+
309
+ if output_format == "csv":
310
+ # #1765: the *server's* serializer, applied to the raw wire result —
311
+ # never a re-serialisation of the human table. That is what makes
312
+ # these bytes identical to `GET /report/{id}?format=csv`, and what
313
+ # keeps `started_at` an epoch instead of the `13h ago` this module
314
+ # renders two functions up.
315
+ from coord.reports import result_to_csv # noqa: PLC0415
316
+
317
+ # nl=False: the serializer already terminates every line, and click
318
+ # would otherwise append a stray blank line that the daemon's bytes
319
+ # do not have.
320
+ click.echo(result_to_csv(result), nl=False)
321
+ return
322
+
323
+ window = result.get("window") or [None, None]
324
+ click.echo(
325
+ f"{result.get('report_id')} — window {_abs_time(window[0])} → "
326
+ f"{_abs_time(window[1])}"
327
+ )
328
+ rows = result.get("rows") or []
329
+ click.echo("")
330
+ if not rows:
331
+ click.echo("(no activity in this window)")
332
+ else:
333
+ for line in _render_table(result):
334
+ click.echo(line)
335
+
336
+ notes = result.get("notes") or []
337
+ if notes:
338
+ click.echo("")
339
+ click.echo(f"notes ({len(notes)}):")
340
+ for note in notes:
341
+ click.echo(f" • {note}")