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/reports.py ADDED
@@ -0,0 +1,1643 @@
1
+ """Report engine (#1742) — a registry of named, parameterised reports folded
2
+ out of the coordinator's own history.
3
+
4
+ The point is to stop paying an Opus coordinator session to hand-roll the same
5
+ aggregation every morning. "What did the fleet do overnight, and where did it
6
+ all end up?" is pure deterministic arithmetic over the audit trail (#1036 /
7
+ #1037) — this module makes it a `coord report run` away, and reproducible.
8
+
9
+ Three layers, deliberately separated so the interesting one is testable:
10
+
11
+ 1. :func:`fold_issue_activity` — **pure**. Takes already-fetched audit
12
+ entries plus an explicit ``(start, end)`` window and returns a
13
+ :class:`ReportResult`. No daemon, no DB, no clock (``generated_at``
14
+ defaults to the window end). This is where every derivation lives, and
15
+ it unit-tests against fixture events.
16
+ 2. :func:`fetch_audit_window` — pagination. The audit read path hard-caps a
17
+ single call at 500 rows (``coord.audit.MAX_LIMIT``); that is a *page
18
+ size*, not a window bound, so this walks the keyset cursor until the
19
+ window is covered and reports ``truncated=True`` if it genuinely could
20
+ not finish. Never silently drops the tail (#1742: "no silent caps").
21
+ 3. :data:`REPORTS` + :func:`run_report` — the registry and its parameter
22
+ validation. Three entries: ``issue-activity``; ``drive-queue-status``
23
+ (#1805), a **live snapshot** of ``drive_queue`` (no window, no audit
24
+ trail, no clock beyond ``generated_at``) rather than a fold over history;
25
+ and ``usage`` (#1763), a cost/token fold over board assignment rows that
26
+ delegates every number to :mod:`coord.usage_rollup` priced with the
27
+ daemon's own loaded ``pricing:`` config — the report that replaced
28
+ coord-tui's ``panel:usage`` and its hardcoded pricing snapshot.
29
+
30
+ The :class:`ReportResult` field names are the **wire contract** the coord-tui
31
+ Reports panel (#1741) renders against, and the CLI's ``--json`` and the
32
+ daemon's ``GET /report/{id}`` both emit exactly this shape — treat them as
33
+ public.
34
+
35
+ Read-only by construction: every query here is a ``SELECT``. Running a
36
+ report must never touch the board (this repo has a recurring
37
+ "``reconcile()`` accretes behaviour" problem; reports do not join it).
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import csv
43
+ import io
44
+ import re
45
+ import time
46
+ from collections.abc import Callable, Iterable, Mapping, Sequence
47
+ from dataclasses import dataclass, field
48
+ from datetime import datetime, timezone
49
+ from typing import Any
50
+
51
+ __all__ = [
52
+ "ReportError",
53
+ "UnknownReportError",
54
+ "ReportParam",
55
+ "ReportDef",
56
+ "ColumnMeta",
57
+ "ReportResult",
58
+ "REPORTS",
59
+ "catalogue",
60
+ "resolve_params",
61
+ "run_report",
62
+ "fetch_audit_window",
63
+ "detect_prior_activity",
64
+ "fold_issue_activity",
65
+ "run_issue_activity",
66
+ "fold_drive_queue_status",
67
+ "run_drive_queue_status",
68
+ "resolve_usage_window",
69
+ "fold_usage",
70
+ "run_usage",
71
+ "parse_duration",
72
+ "result_to_csv",
73
+ "csv_filename",
74
+ ]
75
+
76
+
77
+ class ReportError(ValueError):
78
+ """A bad request against the report engine — unknown parameter, bad value.
79
+
80
+ Callers (the CLI, the daemon) turn this into a clean message + non-zero
81
+ exit / 400, never a traceback.
82
+ """
83
+
84
+
85
+ class UnknownReportError(ReportError):
86
+ """The requested ``report_id`` is not in :data:`REPORTS` (daemon: 404)."""
87
+
88
+
89
+ # ── parameter / definition / result shapes ─────────────────────────────────
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class ReportParam:
94
+ """One parameter of a report, described richly enough that a client can
95
+ build its input form from the catalogue alone (#1741 must NOT hardcode
96
+ the param list).
97
+
98
+ ``kind`` is ``"choice"`` (render a picker over ``choices``) or ``"text"``
99
+ (render a free-text field). ``free_form`` marks a ``choice`` param whose
100
+ ``choices`` are *presets* rather than a whitelist — ``since`` is one:
101
+ ``13h`` is a perfectly good window that nobody wants in a five-item
102
+ picker. ``validate`` is the server-side check, and is the authority; a
103
+ client's form is a convenience on top of it.
104
+ """
105
+
106
+ id: str
107
+ label: str
108
+ kind: str = "text"
109
+ choices: tuple[str, ...] = ()
110
+ default: str = ""
111
+ help: str = ""
112
+ free_form: bool = False
113
+ # Not part of the wire shape — the server-side validator. Raises
114
+ # ReportError (message names the allowed values) on a bad value.
115
+ validate: Callable[[str], None] | None = field(default=None, compare=False)
116
+
117
+ def to_dict(self) -> dict[str, Any]:
118
+ return {
119
+ "id": self.id,
120
+ "label": self.label,
121
+ "kind": self.kind,
122
+ "choices": list(self.choices),
123
+ "default": self.default,
124
+ "help": self.help,
125
+ "free_form": self.free_form,
126
+ }
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class ReportDef:
131
+ """A named report. ``run(**params)`` returns a :class:`ReportResult`."""
132
+
133
+ id: str
134
+ title: str
135
+ description: str
136
+ params: tuple[ReportParam, ...]
137
+ run: Callable[..., "ReportResult"] = field(compare=False)
138
+
139
+ def to_dict(self) -> dict[str, Any]:
140
+ """Catalogue entry — everything a client needs except the callable."""
141
+ return {
142
+ "id": self.id,
143
+ "title": self.title,
144
+ "description": self.description,
145
+ "params": [p.to_dict() for p in self.params],
146
+ }
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class ColumnMeta:
151
+ """Display metadata for one entry of ``ReportResult.columns`` (#1760).
152
+
153
+ Additive, not a retype: ``columns`` stays a bare ``list[str]`` (the
154
+ already-shipped #1741 panel deserialises it as ``Vec<String>`` and must
155
+ keep working unchanged), and row values stay raw — a ``started_at`` cell
156
+ is still an epoch float, a ``machines`` cell is still a list. This is
157
+ only the hint a generic renderer needs to turn that raw value into a
158
+ reasonable cell: ``kind`` says how to format it, ``align``/``weight``
159
+ say how to lay out the column. ``id`` matches the corresponding
160
+ ``columns[]`` entry (and order matches too), so a client can zip them.
161
+ """
162
+
163
+ id: str
164
+ label: str
165
+ # Open vocabulary — a client that meets a `kind` it predates must fall
166
+ # back to plain stringification, never fail to parse:
167
+ # "text" | "int" | "timestamp" | "list" | "enum" | "duration" | "money"
168
+ kind: str
169
+ align: str = "left" # "left" | "right"
170
+ weight: float = 1.0 # relative column width hint
171
+
172
+ def to_dict(self) -> dict[str, Any]:
173
+ return {
174
+ "id": self.id,
175
+ "label": self.label,
176
+ "kind": self.kind,
177
+ "align": self.align,
178
+ "weight": self.weight,
179
+ }
180
+
181
+
182
+ @dataclass
183
+ class ReportResult:
184
+ """The wire contract (#1741 renders against these exact field names).
185
+
186
+ ``columns`` is the ordered list of row keys worth putting in a table;
187
+ ``rows`` may carry extra keys beyond it (``started_before_window``,
188
+ ``last_event_at``, ...) for clients that want the detail. ``notes``
189
+ holds derived anomalies and caveats, rendered under the table.
190
+ ``column_meta`` is additive display metadata, one entry per ``columns``
191
+ entry in the same order (#1760) — a client that ignores it entirely
192
+ still gets byte-identical ``columns``/``rows``.
193
+
194
+ ``totals`` (#1763) is an optional grand-total row for reports that are a
195
+ *fold* with a meaningful sum (``usage``), keyed by the same column ids as
196
+ ``rows``. It is **additive and defaults to ``None``**: reports that have
197
+ no meaningful total (``issue-activity``, ``drive-queue-status``) leave it
198
+ unset, and a client that ignores the key renders exactly as it did
199
+ before. Identity columns are deliberately *absent* from the dict rather
200
+ than filled with a placeholder — a renderer that wants a ``Σ`` marker
201
+ picks one itself, and one that doesn't leaves the cell blank.
202
+ """
203
+
204
+ report_id: str
205
+ generated_at: float
206
+ window: tuple[float, float]
207
+ columns: list[str]
208
+ rows: list[dict]
209
+ notes: list[str]
210
+ column_meta: list[ColumnMeta] = field(default_factory=list)
211
+ totals: dict[str, Any] | None = None
212
+
213
+ def to_dict(self) -> dict[str, Any]:
214
+ return {
215
+ "report_id": self.report_id,
216
+ "generated_at": self.generated_at,
217
+ "window": [self.window[0], self.window[1]],
218
+ "columns": list(self.columns),
219
+ "column_meta": [m.to_dict() for m in self.column_meta],
220
+ "rows": list(self.rows),
221
+ "notes": list(self.notes),
222
+ "totals": None if self.totals is None else dict(self.totals),
223
+ }
224
+
225
+
226
+ # ── time helpers ───────────────────────────────────────────────────────────
227
+
228
+ _DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([smhdw])\s*$", re.IGNORECASE)
229
+ _UNIT_SECONDS = {"s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0, "w": 604800.0}
230
+
231
+
232
+ def parse_duration(raw: str) -> float:
233
+ """``"13h"`` → ``46800.0``. Units: s, m, h, d, w. Raises ReportError."""
234
+ match = _DURATION_RE.match(raw or "")
235
+ if match is None:
236
+ raise ReportError(
237
+ f"not a duration: {raw!r} — expected e.g. '90m', '13h', '3d' "
238
+ "(units: s, m, h, d, w)"
239
+ )
240
+ return float(match.group(1)) * _UNIT_SECONDS[match.group(2).lower()]
241
+
242
+
243
+ def parse_timestamp(raw: str) -> float:
244
+ """Epoch seconds or ISO-8601 → float. Mirrors ``coord audit``'s parsing
245
+ so ``--param until=...`` and ``coord audit --until`` agree."""
246
+ try:
247
+ return float(raw)
248
+ except (TypeError, ValueError):
249
+ pass
250
+ try:
251
+ return datetime.fromisoformat(str(raw).replace("Z", "+00:00")).timestamp()
252
+ except ValueError as exc:
253
+ raise ReportError(
254
+ f"not an epoch number or ISO-8601 timestamp: {raw!r}"
255
+ ) from exc
256
+
257
+
258
+ def _iso(ts: float | None) -> str:
259
+ if ts is None:
260
+ return "?"
261
+ return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime(
262
+ "%Y-%m-%d %H:%M:%SZ"
263
+ )
264
+
265
+
266
+ # ── parameter resolution ───────────────────────────────────────────────────
267
+
268
+
269
+ def resolve_params(report: ReportDef, raw: Mapping[str, Any] | None) -> dict[str, str]:
270
+ """Validate ``raw`` against ``report.params`` and fill in defaults.
271
+
272
+ Unknown keys and bad values raise :class:`ReportError` with a message
273
+ that names what *was* allowed — the CLI and the daemon both surface it
274
+ verbatim, so it has to read well on its own.
275
+ """
276
+ raw = dict(raw or {})
277
+ known = {p.id: p for p in report.params}
278
+ for key in raw:
279
+ if key not in known:
280
+ raise ReportError(
281
+ f"unknown parameter {key!r} for report {report.id!r} — "
282
+ f"known parameters: {', '.join(sorted(known)) or '(none)'}"
283
+ )
284
+ resolved: dict[str, str] = {}
285
+ for param in report.params:
286
+ value = raw.get(param.id)
287
+ value = param.default if value is None or value == "" else str(value)
288
+ _validate_param(param, value)
289
+ resolved[param.id] = value
290
+ return resolved
291
+
292
+
293
+ def _validate_param(param: ReportParam, value: str) -> None:
294
+ if param.validate is not None:
295
+ param.validate(value)
296
+ return
297
+ if param.kind == "choice" and param.choices and not param.free_form:
298
+ if value not in param.choices:
299
+ raise ReportError(
300
+ f"invalid value for {param.id!r}: {value!r} — "
301
+ f"allowed values: {', '.join(param.choices)}"
302
+ )
303
+
304
+
305
+ # ── audit fetch + pagination ───────────────────────────────────────────────
306
+
307
+ # 100 pages x 500 rows = 50k events. Far past any real window; a backstop
308
+ # against an infinite cursor walk, not a coverage limit — hitting it sets
309
+ # truncated=True and the report says so in `notes`.
310
+ MAX_PAGES = 100
311
+
312
+
313
+ def _default_fetch(**kwargs: Any) -> dict:
314
+ from coord.audit import query_audit_log # noqa: PLC0415
315
+
316
+ return query_audit_log(**kwargs)
317
+
318
+
319
+ def fetch_audit_window(
320
+ *,
321
+ since: float,
322
+ until: float,
323
+ repo: str | None = None,
324
+ fetch: Callable[..., Mapping[str, Any]] | None = None,
325
+ page_limit: int | None = None,
326
+ max_pages: int = MAX_PAGES,
327
+ ) -> tuple[list[dict], bool]:
328
+ """Walk the keyset cursor until the whole ``[since, until]`` window is
329
+ covered. Returns ``(entries, truncated)``.
330
+
331
+ ``truncated`` is True only when the walk gave up with rows still
332
+ outstanding (page cap hit, or a page claimed ``has_more`` but handed
333
+ back no cursor) — the caller turns that into an explicit note rather
334
+ than shipping a silently short answer.
335
+ """
336
+ if fetch is None:
337
+ fetch = _default_fetch
338
+ if page_limit is None:
339
+ from coord.audit import MAX_LIMIT # noqa: PLC0415
340
+
341
+ page_limit = MAX_LIMIT
342
+
343
+ entries: list[dict] = []
344
+ cursor: str | None = None
345
+ truncated = True # flipped to False the moment a page says "that's all"
346
+ for _ in range(max(1, int(max_pages))):
347
+ page = fetch(
348
+ since=since,
349
+ until=until,
350
+ repo=repo or None,
351
+ limit=page_limit,
352
+ cursor=cursor,
353
+ ) or {}
354
+ entries.extend(page.get("entries") or [])
355
+ if not page.get("has_more"):
356
+ truncated = False
357
+ break
358
+ cursor = page.get("next_cursor")
359
+ if not cursor:
360
+ # has_more with no cursor — can't advance; stop rather than loop.
361
+ break
362
+ return entries, truncated
363
+
364
+
365
+ # ── issue-activity: the fold ───────────────────────────────────────────────
366
+
367
+ ISSUE_ACTIVITY_COLUMNS = [
368
+ "repo",
369
+ "issue",
370
+ "title",
371
+ "started_at",
372
+ "machines",
373
+ "fix_iterations",
374
+ "test_verdicts",
375
+ "review_verdicts",
376
+ "merged_at",
377
+ "drive_exit",
378
+ "outcome",
379
+ ]
380
+
381
+ # One entry per ISSUE_ACTIVITY_COLUMNS entry, same order (#1760) — the
382
+ # display metadata a generic renderer (CLI table, coord-tui panel) needs to
383
+ # format a raw row value without hardcoding per-report field knowledge.
384
+ ISSUE_ACTIVITY_COLUMN_META = [
385
+ ColumnMeta(id="repo", label="Repo", kind="text"),
386
+ ColumnMeta(id="issue", label="Issue", kind="int", align="right"),
387
+ ColumnMeta(id="title", label="Title", kind="text", weight=3.0),
388
+ ColumnMeta(id="started_at", label="Started", kind="timestamp"),
389
+ ColumnMeta(id="machines", label="Machines", kind="list"),
390
+ ColumnMeta(id="fix_iterations", label="Fixes", kind="int", align="right"),
391
+ ColumnMeta(id="test_verdicts", label="Tests", kind="list"),
392
+ ColumnMeta(id="review_verdicts", label="Reviews", kind="list"),
393
+ ColumnMeta(id="merged_at", label="Merged", kind="timestamp"),
394
+ ColumnMeta(id="drive_exit", label="Drive Exit", kind="text"),
395
+ ColumnMeta(id="outcome", label="Outcome", kind="enum"),
396
+ ]
397
+
398
+ _TEST_EVENTS = ("test_passed", "test_failed", "test_skipped")
399
+ _REVIEW_EVENTS = ("review_approve", "review_request-changes")
400
+
401
+ # An issue with no drive_exit and no event for this long by the end of the
402
+ # window is called `stalled` rather than `in-flight`. Two hours is well past
403
+ # any normal gate turnaround in this fleet.
404
+ STALL_QUIET_SECONDS = 2 * 3600.0
405
+
406
+ # A work-like dispatch is a real attempt at the issue; a review/smoke/plan
407
+ # dispatch is not, and must not count as a fix iteration.
408
+ _WORK_LIKE_TYPES = frozenset({"work", "mock-author", "test-author"})
409
+
410
+
411
+ def fold_issue_activity(
412
+ entries: Iterable[Mapping[str, Any]],
413
+ window: tuple[float, float],
414
+ *,
415
+ titles: Mapping[tuple[str, int], str] | None = None,
416
+ generated_at: float | None = None,
417
+ truncated: bool = False,
418
+ prior_activity: frozenset[tuple[str, int]] = frozenset(),
419
+ ) -> ReportResult:
420
+ """Fold audit entries into one row per ``(repo, issue)``.
421
+
422
+ **Pure** — no DB, no daemon, no clock. ``generated_at`` defaults to the
423
+ window end so a frozen-clock test gets a deterministic result.
424
+
425
+ ``entries`` may arrive in any order (the audit read path is newest-first);
426
+ they are sorted ascending on ``(ts, id)`` here, which is what makes
427
+ "first dispatch", "last merge" and the ordered verdict lists mean what
428
+ they say.
429
+
430
+ ``prior_activity`` (#1760) is the one fact this pure fold cannot derive
431
+ for itself: the set of ``(repo, issue)`` keys that have *any* audit event
432
+ before the window opened, as determined by the caller's bounded
433
+ look-back (:func:`detect_prior_activity`). Without it, an issue whose
434
+ real start predates the window but which was re-dispatched inside it
435
+ reads as "started here, zero fixes" — a real timestamp and a real count
436
+ that are both wrong, with nothing in the row saying so. With it, that
437
+ row instead reports ``started_at=None``, ``started_before_window=True``
438
+ and ``counts_partial=True``, and every in-window work dispatch counts as
439
+ a fix (the issue was already running when the window opened, so each one
440
+ is a re-dispatch). Default is empty, so existing callers are unaffected
441
+ in shape.
442
+ """
443
+ start, end = float(window[0]), float(window[1])
444
+ title_map = dict(titles or {})
445
+
446
+ usable: list[Mapping[str, Any]] = []
447
+ orphans = 0
448
+ for entry in entries:
449
+ if entry.get("repo") and entry.get("issue") is not None:
450
+ usable.append(entry)
451
+ else:
452
+ orphans += 1
453
+ usable.sort(key=lambda e: (float(e.get("ts") or 0.0), int(e.get("id") or 0)))
454
+
455
+ groups: dict[tuple[str, int], list[Mapping[str, Any]]] = {}
456
+ for entry in usable:
457
+ key = (str(entry["repo"]), int(entry["issue"]))
458
+ groups.setdefault(key, []).append(entry)
459
+
460
+ rows = [
461
+ _fold_one_issue(
462
+ repo,
463
+ issue,
464
+ evs,
465
+ end,
466
+ title_map.get((repo, issue)),
467
+ had_prior_activity=(repo, issue) in prior_activity,
468
+ )
469
+ for (repo, issue), evs in groups.items()
470
+ ]
471
+ # Most-recently-active first: the morning question is "what moved", and
472
+ # the thing that moved last is the thing still moving.
473
+ rows.sort(key=lambda r: r["last_event_at"] or 0.0, reverse=True)
474
+
475
+ notes: list[str] = []
476
+ if truncated:
477
+ notes.append(
478
+ f"TRUNCATED: the window {_iso(start)} → {_iso(end)} could not be "
479
+ f"fully fetched from the audit trail ({len(usable) + orphans} "
480
+ "events read before the page cap). Rows below cover only part of "
481
+ "the window — narrow it with a smaller `since` for a complete "
482
+ "answer."
483
+ )
484
+ if orphans:
485
+ notes.append(
486
+ f"{orphans} event(s) in the window carry no repo/issue "
487
+ "(fleet-level housekeeping) and are not represented in any row."
488
+ )
489
+ notes.extend(_derive_notes(rows))
490
+
491
+ return ReportResult(
492
+ report_id="issue-activity",
493
+ generated_at=end if generated_at is None else float(generated_at),
494
+ window=(start, end),
495
+ columns=list(ISSUE_ACTIVITY_COLUMNS),
496
+ column_meta=list(ISSUE_ACTIVITY_COLUMN_META),
497
+ rows=rows,
498
+ notes=notes,
499
+ )
500
+
501
+
502
+ def _fold_one_issue(
503
+ repo: str,
504
+ issue: int,
505
+ events: Sequence[Mapping[str, Any]],
506
+ window_end: float,
507
+ title: str | None,
508
+ *,
509
+ had_prior_activity: bool = False,
510
+ ) -> dict[str, Any]:
511
+ started_at: float | None = None
512
+ machines: list[str] = []
513
+ work_dispatches = 0
514
+ test_verdicts: list[str] = []
515
+ review_verdicts: list[str] = []
516
+ merged_at: float | None = None
517
+ drive_exit: dict[str, Any] | None = None
518
+
519
+ for entry in events:
520
+ category = entry.get("category")
521
+ event_type = entry.get("event_type")
522
+ ts = float(entry.get("ts") or 0.0)
523
+ details = entry.get("details") or {}
524
+ if not isinstance(details, Mapping):
525
+ details = {}
526
+ machine = entry.get("machine")
527
+ if machine and machine not in machines:
528
+ machines.append(machine)
529
+
530
+ if category == "drive" and event_type == "drive_started":
531
+ if started_at is None:
532
+ started_at = ts
533
+ elif category == "dispatch" and event_type == "dispatched":
534
+ # `details.type` is absent on the oldest rows; "work" is the
535
+ # assignment default, so that is the right assumption.
536
+ if (details.get("type") or "work") in _WORK_LIKE_TYPES:
537
+ work_dispatches += 1
538
+ if started_at is None:
539
+ started_at = ts
540
+ elif category == "test" and event_type in _TEST_EVENTS:
541
+ test_verdicts.append(str(event_type)[len("test_"):])
542
+ elif category == "review" and event_type in _REVIEW_EVENTS:
543
+ review_verdicts.append(str(event_type)[len("review_"):])
544
+ elif category == "merge" and event_type == "merged":
545
+ merged_at = ts
546
+ elif category == "drive" and event_type == "drive_exited":
547
+ drive_exit = {
548
+ "at": ts,
549
+ "exit_code": details.get("exit_code"),
550
+ "reason": details.get("reason") or details.get("error"),
551
+ }
552
+
553
+ if had_prior_activity:
554
+ # The caller's look-back (#1760) found an event before the window
555
+ # opened — this issue was already running. Report that plainly
556
+ # rather than claiming a start the window cannot support: no start
557
+ # time, and every in-window work dispatch is a re-dispatch (not
558
+ # "first dispatch, zero fixes").
559
+ started_at = None
560
+ started_before_window = True
561
+ fix_iterations = work_dispatches
562
+ else:
563
+ # "In-window activity, but no start event in it" — the issue began
564
+ # before the window opened. Reported as started_at=None + this flag
565
+ # rather than as a bogus start time taken from the first event we
566
+ # happened to see.
567
+ started_before_window = started_at is None
568
+ # Every work dispatch after the *first* one is a fix iteration.
569
+ fix_iterations = max(0, work_dispatches - 1)
570
+ # counts_partial is narrower than started_before_window: the latter can
571
+ # also fire from the plain "no start event in this window" inference
572
+ # above, which doesn't know whether fix_iterations/test_verdicts are
573
+ # complete or merely empty. Only a confirmed look-back hit means the
574
+ # counts are a known lower bound.
575
+ counts_partial = had_prior_activity
576
+
577
+ first_event_at = float(events[0].get("ts") or 0.0) if events else None
578
+ last_event_at = float(events[-1].get("ts") or 0.0) if events else None
579
+
580
+ return {
581
+ "repo": repo,
582
+ "issue": issue,
583
+ "title": title,
584
+ "started_at": started_at,
585
+ "started_before_window": started_before_window,
586
+ "machines": machines,
587
+ "fix_iterations": fix_iterations,
588
+ "counts_partial": counts_partial,
589
+ "test_verdicts": test_verdicts,
590
+ "review_verdicts": review_verdicts,
591
+ "merged_at": merged_at,
592
+ "drive_exit": drive_exit,
593
+ "outcome": _derive_outcome(merged_at, drive_exit, last_event_at, window_end),
594
+ "first_event_at": first_event_at,
595
+ "last_event_at": last_event_at,
596
+ "event_count": len(events),
597
+ }
598
+
599
+
600
+ def _nonzero_exit(drive_exit: Mapping[str, Any] | None) -> bool:
601
+ """True when the driver did NOT exit clean. A missing/None ``exit_code``
602
+ counts — that shape is written by the crash path
603
+ (``DriveRunner._drive_exit_summary``), which is exactly as unclean as a
604
+ non-zero code."""
605
+ return drive_exit is not None and drive_exit.get("exit_code") != 0
606
+
607
+
608
+ def _derive_outcome(
609
+ merged_at: float | None,
610
+ drive_exit: Mapping[str, Any] | None,
611
+ last_event_at: float | None,
612
+ window_end: float,
613
+ ) -> str:
614
+ if merged_at is not None:
615
+ return "merged"
616
+ if drive_exit is not None:
617
+ # The driver is gone. Non-zero => it gave up loudly; clean exit with
618
+ # nothing landed => it gave up quietly. Neither is in-flight.
619
+ return "failed" if _nonzero_exit(drive_exit) else "stalled"
620
+ if last_event_at is not None and (window_end - last_event_at) > STALL_QUIET_SECONDS:
621
+ return "stalled"
622
+ return "in-flight"
623
+
624
+
625
+ def _derive_notes(rows: Sequence[Mapping[str, Any]]) -> list[str]:
626
+ """Anomalies worth a human's eye, derived from the folded rows.
627
+
628
+ The load-bearing one is the first: a driver that exits non-zero on an
629
+ issue that then merges anyway. That is the real 2026-08-02 case
630
+ (#1631 exited 1 with "merge attempted 3 times without landing" at 21:48;
631
+ the merge landed at 22:01) — a driver giving up on a merge that was
632
+ still converging, otherwise invisible in both the event stream and the
633
+ final board state.
634
+ """
635
+ notes: list[str] = []
636
+ for row in rows:
637
+ ident = f"{row['repo']}#{row['issue']}"
638
+ drive_exit = row.get("drive_exit")
639
+ if drive_exit and _nonzero_exit(drive_exit) and row.get("merged_at") is not None:
640
+ reason = drive_exit.get("reason")
641
+ reason_part = f" ({reason})" if reason else ""
642
+ notes.append(
643
+ f"{ident}: driver exited exit_code="
644
+ f"{drive_exit.get('exit_code')!r} at {_iso(drive_exit.get('at'))}"
645
+ f"{reason_part}, but the merge landed at "
646
+ f"{_iso(row['merged_at'])} — the driver gave up on a merge "
647
+ "that was still converging."
648
+ )
649
+ if (
650
+ row.get("merged_at") is not None
651
+ and row.get("test_verdicts")
652
+ and row["test_verdicts"][-1] == "failed"
653
+ ):
654
+ notes.append(
655
+ f"{ident}: merged at {_iso(row['merged_at'])} with the last "
656
+ "in-window Test-gate verdict still 'failed'."
657
+ )
658
+ if int(row.get("fix_iterations") or 0) >= 3:
659
+ notes.append(
660
+ f"{ident}: {row['fix_iterations']} fix iterations in this "
661
+ "window — the work is not converging on its own."
662
+ )
663
+ if row.get("counts_partial"):
664
+ # #1760: this issue was already running when the window opened
665
+ # (the caller's look-back found an earlier event) — say so
666
+ # explicitly rather than let a real-looking fix_iterations/
667
+ # test_verdicts count pass as complete.
668
+ notes.append(
669
+ f"{ident}: started before this window — fix_iterations and "
670
+ "test_verdicts are lower bounds, not the full count. Widen "
671
+ "`since` to see the real start."
672
+ )
673
+ elif (
674
+ "request-changes" in (row.get("review_verdicts") or [])
675
+ and int(row.get("fix_iterations") or 0) == 0
676
+ ):
677
+ # #1760: a request-changes verdict implies at least one
678
+ # re-dispatch happened. fix_iterations=0 with counts_partial
679
+ # False (the elif) means the fold believes it saw the whole
680
+ # window's activity — this combination should not be reachable,
681
+ # and if it appears the row is self-contradictory.
682
+ notes.append(
683
+ f"{ident}: review verdict 'request-changes' with "
684
+ "fix_iterations=0 — this combination should not happen; the "
685
+ "row is internally inconsistent."
686
+ )
687
+ return notes
688
+
689
+
690
+ # ── issue-activity: the runner ─────────────────────────────────────────────
691
+
692
+
693
+ def _lookup_titles(
694
+ keys: Iterable[tuple[str, int]],
695
+ ) -> dict[tuple[str, int], str]:
696
+ """Best-effort issue titles from the local DB. Read-only, and failure is
697
+ not an error — a missing title renders as ``None`` in the row, which is
698
+ strictly better than failing the whole report over cosmetics."""
699
+ keys = sorted(set(keys))
700
+ if not keys:
701
+ return {}
702
+ out: dict[tuple[str, int], str] = {}
703
+ try:
704
+ from coord.db import get_connection # noqa: PLC0415
705
+
706
+ conn = get_connection()
707
+ for repo, number in keys:
708
+ row = conn.execute(
709
+ "SELECT title FROM issues WHERE repo_name = ? AND number = ?",
710
+ (repo, number),
711
+ ).fetchone()
712
+ if row is not None and row["title"]:
713
+ out[(repo, number)] = row["title"]
714
+ continue
715
+ row = conn.execute(
716
+ "SELECT issue_title FROM assignments WHERE repo_name = ? "
717
+ "AND issue_number = ? AND issue_title IS NOT NULL "
718
+ "ORDER BY rowid DESC LIMIT 1",
719
+ (repo, number),
720
+ ).fetchone()
721
+ if row is not None and row["issue_title"]:
722
+ out[(repo, number)] = row["issue_title"]
723
+ except Exception: # noqa: BLE001 — titles are cosmetic; never fail a report
724
+ return out
725
+ return out
726
+
727
+
728
+ def detect_prior_activity(
729
+ keys: Iterable[tuple[str, int]],
730
+ *,
731
+ until: float,
732
+ fetch: Callable[..., Mapping[str, Any]],
733
+ ) -> frozenset[tuple[str, int]]:
734
+ """Bounded look-back (#1760): which ``(repo, issue)`` keys already have
735
+ at least one audit event before ``until`` (the window start)?
736
+
737
+ One query per issue in ``keys`` — not one per event, not an unbounded
738
+ scan. Same query path as the window fetch (``fetch`` is the same
739
+ callable, real or injected), just ``until=window_start``, a per-issue
740
+ filter, and ``limit=1`` newest-first: the fold only needs a yes/no per
741
+ issue, not the events themselves.
742
+ """
743
+ prior: set[tuple[str, int]] = set()
744
+ for key_repo, key_issue in sorted(set(keys)):
745
+ page = fetch(
746
+ since=None,
747
+ until=until,
748
+ repo=key_repo,
749
+ issue=key_issue,
750
+ limit=1,
751
+ cursor=None,
752
+ ) or {}
753
+ if page.get("entries"):
754
+ prior.add((key_repo, key_issue))
755
+ return frozenset(prior)
756
+
757
+
758
+ def run_issue_activity(
759
+ *,
760
+ since: str = "24h",
761
+ until: str = "",
762
+ repo: str = "",
763
+ now: float | None = None,
764
+ fetch: Callable[..., Mapping[str, Any]] | None = None,
765
+ title_lookup: Callable[..., Mapping[tuple[str, int], str]] | None = None,
766
+ ) -> ReportResult:
767
+ """Fetch the window (paginated) and fold it. ``now``/``fetch``/
768
+ ``title_lookup`` are test seams; the report's own parameters are
769
+ ``since``/``until``/``repo``."""
770
+ generated_at = time.time() if now is None else float(now)
771
+ end = parse_timestamp(until) if until else generated_at
772
+ start = end - parse_duration(since)
773
+
774
+ fetch_fn = _default_fetch if fetch is None else fetch
775
+
776
+ entries, truncated = fetch_audit_window(
777
+ since=start, until=end, repo=repo or None, fetch=fetch_fn
778
+ )
779
+ keys = {
780
+ (str(e["repo"]), int(e["issue"]))
781
+ for e in entries
782
+ if e.get("repo") and e.get("issue") is not None
783
+ }
784
+ lookup = _lookup_titles if title_lookup is None else title_lookup
785
+ titles = lookup(keys)
786
+ prior_activity = detect_prior_activity(keys, until=start, fetch=fetch_fn)
787
+ return fold_issue_activity(
788
+ entries,
789
+ (start, end),
790
+ titles=titles,
791
+ generated_at=generated_at,
792
+ truncated=truncated,
793
+ prior_activity=prior_activity,
794
+ )
795
+
796
+
797
+ # ── drive-queue-status: a live snapshot, not a fold ────────────────────────
798
+ #
799
+ # #1805: "what is queued, and is it moving?" without a CLI round-trip. Unlike
800
+ # issue-activity this is not a fold over an audit-trail window — it is a
801
+ # point-in-time read of `drive_queue` via `coord.state.list_drive_queue`
802
+ # (daemon-or-local already handled there), so `window` is degenerate:
803
+ # `(generated_at, generated_at)`. `drive_queue` has no `completed_at` and
804
+ # `coord/drive_queue.py` emits no audit events, so there is no data source
805
+ # for a queue *history* report — see this issue's "Out of scope".
806
+
807
+ DRIVE_QUEUE_STATUS_COLUMNS = [
808
+ "position",
809
+ "repo",
810
+ "issue",
811
+ "title",
812
+ "state",
813
+ "machine",
814
+ "attempts",
815
+ "deferrals",
816
+ "last_reason",
817
+ "enqueued_at",
818
+ "launched_at",
819
+ "hold_state",
820
+ "after",
821
+ ]
822
+
823
+ # One entry per DRIVE_QUEUE_STATUS_COLUMNS entry, same order (#1760).
824
+ DRIVE_QUEUE_STATUS_COLUMN_META = [
825
+ ColumnMeta(id="position", label="Pos", kind="int", align="right"),
826
+ ColumnMeta(id="repo", label="Repo", kind="text"),
827
+ ColumnMeta(id="issue", label="Issue", kind="int", align="right"),
828
+ ColumnMeta(id="title", label="Title", kind="text", weight=2.0),
829
+ ColumnMeta(id="state", label="State", kind="enum"),
830
+ ColumnMeta(id="machine", label="Machine", kind="text"),
831
+ ColumnMeta(id="attempts", label="Attempts", kind="int", align="right"),
832
+ ColumnMeta(id="deferrals", label="Deferrals", kind="int", align="right"),
833
+ ColumnMeta(id="last_reason", label="Last Reason", kind="text", weight=3.0),
834
+ ColumnMeta(id="enqueued_at", label="Enqueued", kind="timestamp"),
835
+ ColumnMeta(id="launched_at", label="Launched", kind="timestamp"),
836
+ ColumnMeta(id="hold_state", label="Hold", kind="enum"),
837
+ ColumnMeta(id="after", label="After", kind="list"),
838
+ ]
839
+
840
+ # The #1794 tell: an entry that has already burned at least one launch
841
+ # attempt is the thing an operator most wants shouted at them.
842
+ _RETRIED_ATTEMPTS_THRESHOLD = 1
843
+
844
+
845
+ def fold_drive_queue_status(
846
+ entries: Iterable[Mapping[str, Any]],
847
+ generated_at: float,
848
+ *,
849
+ titles: Mapping[tuple[str, int], str] | None = None,
850
+ queue_escalation: Mapping[str, Any] | None = None,
851
+ ) -> ReportResult:
852
+ """Fold already-fetched ``drive_queue`` rows into a snapshot ``ReportResult``.
853
+
854
+ **Pure** — no DB, no daemon, no clock: ``entries`` is whatever
855
+ :func:`coord.state.list_drive_queue` returned (raw column names,
856
+ ``after_json`` already decoded to a list) and ``generated_at`` is the
857
+ caller's clock reading, reused verbatim for both ends of ``window`` since
858
+ a live snapshot has no meaningful range.
859
+
860
+ ``entries`` arrives pre-ordered (``list_drive_queue`` is
861
+ ``ORDER BY position, id``) — this fold does not re-sort.
862
+ """
863
+ title_map = dict(titles or {})
864
+ rows: list[dict[str, Any]] = []
865
+ for entry in entries:
866
+ repo = str(entry.get("repo_name") or "")
867
+ issue = int(entry.get("issue_number") or 0)
868
+ rows.append(
869
+ {
870
+ "position": int(entry.get("position") or 0),
871
+ "repo": repo,
872
+ "issue": issue,
873
+ "title": title_map.get((repo, issue)),
874
+ "state": entry.get("state") or "",
875
+ "machine": entry.get("machine") or "",
876
+ "attempts": int(entry.get("attempts") or 0),
877
+ "deferrals": int(entry.get("deferrals") or 0),
878
+ "last_reason": entry.get("last_reason") or "",
879
+ "enqueued_at": entry.get("enqueued_at"),
880
+ "launched_at": entry.get("launched_at"),
881
+ "hold_state": entry.get("hold_state") or "",
882
+ "after": list(entry.get("after_json") or []),
883
+ # Extra keys beyond `columns` — ReportResult's contract
884
+ # explicitly allows this for clients that want the detail.
885
+ "session_name": entry.get("session_name") or "",
886
+ "hold_reason": entry.get("hold_reason") or "",
887
+ "resume_when": entry.get("resume_when") or "",
888
+ }
889
+ )
890
+
891
+ notes: list[str] = []
892
+ if not rows:
893
+ notes.append("The drive queue is empty.")
894
+ else:
895
+ from coord.drive_queue import ( # noqa: PLC0415
896
+ STATE_BLOCKED,
897
+ STATE_DONE,
898
+ STATE_FAILED,
899
+ STATE_RUNNING,
900
+ STATE_WAITING,
901
+ TERMINAL_QUEUE_STATES,
902
+ )
903
+
904
+ counts: dict[str, int] = {}
905
+ for r in rows:
906
+ counts[r["state"]] = counts.get(r["state"], 0) + 1
907
+ # The headline is entries the queue will still act on — `done` (and
908
+ # any other terminal state) is run history, not queue depth, so it
909
+ # is excluded here rather than folded into `len(rows)` (#1855).
910
+ queued = sum(n for state, n in counts.items() if state not in TERMINAL_QUEUE_STATES)
911
+
912
+ def _ordered(present: set[str], preferred: tuple[str, ...]) -> list[str]:
913
+ # `preferred` is display polish only — any state absent from it
914
+ # (a future addition to drive_queue.py's five, or one we simply
915
+ # forgot to list) still surfaces, just alphabetically after the
916
+ # known ones, so nothing can silently vanish the way `blocked`
917
+ # did before this fix.
918
+ return [s for s in preferred if s in present] + sorted(present - set(preferred))
919
+
920
+ non_terminal_states = _ordered(
921
+ {s for s in counts if s not in TERMINAL_QUEUE_STATES},
922
+ (STATE_RUNNING, STATE_WAITING),
923
+ )
924
+ # `blocked`/`failed` are the states that need a human — call them
925
+ # out ahead of the benign `done` count, not appended after it.
926
+ terminal_states = _ordered(
927
+ {s for s in counts if s in TERMINAL_QUEUE_STATES},
928
+ (STATE_BLOCKED, STATE_FAILED, STATE_DONE),
929
+ )
930
+
931
+ breakdown = ", ".join(f"{counts[s]} {s}" for s in non_terminal_states)
932
+ headline = f"{queued} entr{'y' if queued == 1 else 'ies'} queued"
933
+ if breakdown:
934
+ headline += f" ({breakdown})"
935
+ terminal_parts = [f"{counts[s]} {s}" for s in terminal_states]
936
+ if terminal_parts:
937
+ headline += " · " + " · ".join(terminal_parts)
938
+ notes.append(headline + ".")
939
+ retried = [r for r in rows if r["attempts"] >= _RETRIED_ATTEMPTS_THRESHOLD]
940
+ if retried:
941
+ named = ", ".join(
942
+ f"{r['repo']}#{r['issue']} (attempts={r['attempts']})" for r in retried
943
+ )
944
+ notes.append(f"attempts>=1: {named}.")
945
+ if queue_escalation:
946
+ reason = queue_escalation.get("reason") or "(no reason recorded)"
947
+ stage = queue_escalation.get("stage") or "?"
948
+ notes.append(
949
+ f"standing queue-level escalation: stage={stage!r} — {reason}"
950
+ )
951
+
952
+ return ReportResult(
953
+ report_id="drive-queue-status",
954
+ generated_at=generated_at,
955
+ window=(generated_at, generated_at),
956
+ columns=list(DRIVE_QUEUE_STATUS_COLUMNS),
957
+ column_meta=list(DRIVE_QUEUE_STATUS_COLUMN_META),
958
+ rows=rows,
959
+ notes=notes,
960
+ )
961
+
962
+
963
+ def _default_list_drive_queue(repo: str | None) -> list[dict]:
964
+ from coord.state import list_drive_queue # noqa: PLC0415
965
+
966
+ return list_drive_queue(repo)
967
+
968
+
969
+ def _default_queue_escalation() -> Mapping[str, Any] | None:
970
+ """The standing queue-level escalation record (#1754's synthetic key),
971
+ if one exists. A plain read — never runs a tick — so it is safe to
972
+ surface here; best-effort, mirroring :func:`_lookup_titles`."""
973
+ try:
974
+ from coord.drive_queue import ( # noqa: PLC0415
975
+ QUEUE_ALERT_ISSUE,
976
+ QUEUE_ALERT_REPO,
977
+ )
978
+ from coord.state import get_drive_escalation # noqa: PLC0415
979
+
980
+ return get_drive_escalation(QUEUE_ALERT_REPO, QUEUE_ALERT_ISSUE)
981
+ except Exception: # noqa: BLE001 — cosmetic; never fail the report over it
982
+ return None
983
+
984
+
985
+ def run_drive_queue_status(
986
+ *,
987
+ repo: str = "",
988
+ now: float | None = None,
989
+ fetch: Callable[[str | None], Sequence[Mapping[str, Any]]] | None = None,
990
+ title_lookup: Callable[..., Mapping[tuple[str, int], str]] | None = None,
991
+ escalation_lookup: Callable[[], Mapping[str, Any] | None] | None = None,
992
+ ) -> ReportResult:
993
+ """Fetch the live queue and fold it. ``now``/``fetch``/``title_lookup``/
994
+ ``escalation_lookup`` are test seams (mirrors :func:`run_issue_activity`);
995
+ the report's own parameter is ``repo``.
996
+
997
+ Read-only and tick-free by construction: the only call here is
998
+ ``list_drive_queue`` (or the injected ``fetch``) — never ``plan_tick``.
999
+ """
1000
+ generated_at = time.time() if now is None else float(now)
1001
+ fetch_fn = _default_list_drive_queue if fetch is None else fetch
1002
+ entries = list(fetch_fn(repo or None) or [])
1003
+
1004
+ keys = {
1005
+ (str(e["repo_name"]), int(e["issue_number"]))
1006
+ for e in entries
1007
+ if e.get("repo_name") and e.get("issue_number") is not None
1008
+ }
1009
+ lookup = _lookup_titles if title_lookup is None else title_lookup
1010
+ titles = lookup(keys)
1011
+
1012
+ esc_lookup = _default_queue_escalation if escalation_lookup is None else escalation_lookup
1013
+ queue_escalation = esc_lookup()
1014
+
1015
+ return fold_drive_queue_status(
1016
+ entries,
1017
+ generated_at,
1018
+ titles=titles,
1019
+ queue_escalation=queue_escalation,
1020
+ )
1021
+
1022
+
1023
+ # ── usage: the per-issue / per-repo cost + token rollup ────────────────────
1024
+ #
1025
+ # #1763. This is a **correctness fix**, not a consolidation. `coord-tui`'s
1026
+ # `panel:usage` was a Rust port of `coord/usage_rollup.py` carrying a
1027
+ # hardcoded snapshot of `coord.config.PricingConfig`'s shipped defaults, so
1028
+ # an operator who overrode `pricing:` in coordinator.yml changed what
1029
+ # `coord usage` reported and left the panel confidently showing different
1030
+ # numbers (the durable #1116 finding). The daemon holds the config, so the
1031
+ # daemon does the arithmetic: everything below *calls* `usage_rollup.rollup`
1032
+ # / `rollup_by_stage` with the loaded `PricingConfig` and reimplements none
1033
+ # of its window predicate, leg-cost rule or default sort.
1034
+
1035
+ USAGE_WINDOW_CHOICES = ("today", "week", "month", "7d", "30d")
1036
+ USAGE_GROUP_BY_CHOICES = ("issue", "repo")
1037
+
1038
+ # Columns depend on `group_by`: a repo-grouped row IS the whole repo, so it
1039
+ # carries no issue number and no title (same shape the retired panel used).
1040
+ USAGE_ISSUE_COLUMNS = [
1041
+ "issue",
1042
+ "repo",
1043
+ "title",
1044
+ "legs",
1045
+ "tokens_in",
1046
+ "tokens_out",
1047
+ "cost_captured",
1048
+ "cost_est",
1049
+ "cost_total",
1050
+ ]
1051
+
1052
+ USAGE_REPO_COLUMNS = [
1053
+ "repo",
1054
+ "legs",
1055
+ "tokens_in",
1056
+ "tokens_out",
1057
+ "cost_captured",
1058
+ "cost_est",
1059
+ "cost_total",
1060
+ ]
1061
+
1062
+ # #1760 display metadata, indexed by column id and emitted in `columns`
1063
+ # order — a large `weight` on `title`, `int`/`right` for the counts, and
1064
+ # `money`/`right` for the three dollar columns. `money` is a *generic* kind
1065
+ # (the vocabulary is open, see ColumnMeta): a client that predates it falls
1066
+ # back to plain stringification and still shows the number.
1067
+ _USAGE_COLUMN_META: dict[str, ColumnMeta] = {
1068
+ "issue": ColumnMeta(id="issue", label="Issue", kind="int", align="right", weight=0.8),
1069
+ "repo": ColumnMeta(id="repo", label="Repo", kind="text", weight=1.5),
1070
+ "title": ColumnMeta(id="title", label="Title", kind="text", weight=4.0),
1071
+ "legs": ColumnMeta(id="legs", label="Legs", kind="int", align="right", weight=0.6),
1072
+ "tokens_in": ColumnMeta(id="tokens_in", label="Tok In", kind="int", align="right"),
1073
+ "tokens_out": ColumnMeta(id="tokens_out", label="Tok Out", kind="int", align="right"),
1074
+ "cost_captured": ColumnMeta(
1075
+ id="cost_captured", label="Cost $", kind="money", align="right"
1076
+ ),
1077
+ "cost_est": ColumnMeta(id="cost_est", label="Est ~$", kind="money", align="right"),
1078
+ "cost_total": ColumnMeta(id="cost_total", label="Total $", kind="money", align="right"),
1079
+ }
1080
+
1081
+ # Dollar figures are rounded before they go on the wire so a float artefact
1082
+ # (2.8000000000000003) never reaches a generic renderer. Six places is far
1083
+ # below any real per-leg cost and above any rounding that could change a
1084
+ # reported cent.
1085
+ _USAGE_COST_PLACES = 6
1086
+
1087
+
1088
+ def usage_columns(group_by: str) -> list[str]:
1089
+ """The ``columns`` list for *group_by*. Raises :class:`ReportError`."""
1090
+ if group_by == "issue":
1091
+ return list(USAGE_ISSUE_COLUMNS)
1092
+ if group_by == "repo":
1093
+ return list(USAGE_REPO_COLUMNS)
1094
+ raise ReportError(
1095
+ f"invalid value for 'group_by': {group_by!r} — "
1096
+ f"allowed values: {', '.join(USAGE_GROUP_BY_CHOICES)}"
1097
+ )
1098
+
1099
+
1100
+ def resolve_usage_window(window: str, now: float | None = None):
1101
+ """Resolve a ``window`` parameter to a :class:`coord.usage_rollup.TimeWindow`.
1102
+
1103
+ Every preset is *called* from :mod:`coord.usage_rollup`, never
1104
+ reimplemented — that module owns the calendar (this is precisely what the
1105
+ retired panel hand-rolled a civil calendar to duplicate).
1106
+ """
1107
+ from coord.usage_rollup import ( # noqa: PLC0415
1108
+ Window,
1109
+ window_month,
1110
+ window_today,
1111
+ window_week,
1112
+ )
1113
+
1114
+ if window == "today":
1115
+ return window_today(now)
1116
+ if window == "week":
1117
+ return window_week(now)
1118
+ if window == "month":
1119
+ return window_month(now)
1120
+ if window in ("7d", "30d"):
1121
+ # Window.since is the *bounded* variant: [now - spec, now).
1122
+ return Window.since(window, now)
1123
+ raise ReportError(
1124
+ f"invalid value for 'window': {window!r} — "
1125
+ f"allowed values: {', '.join(USAGE_WINDOW_CHOICES)}"
1126
+ )
1127
+
1128
+
1129
+ def _usage_row_title(leg_rows: Sequence[Mapping[str, Any]]) -> str | None:
1130
+ for row in leg_rows:
1131
+ title = row.get("issue_title")
1132
+ if title:
1133
+ return str(title)
1134
+ return None
1135
+
1136
+
1137
+ def _usage_metrics(group: Any) -> dict[str, Any]:
1138
+ """The numeric half of a row (or of ``totals``) — identical for both."""
1139
+ return {
1140
+ "legs": int(group.legs),
1141
+ "tokens_in": int(group.tokens.input),
1142
+ "tokens_out": int(group.tokens.output),
1143
+ "cost_captured": round(float(group.cost_captured), _USAGE_COST_PLACES),
1144
+ "cost_est": round(float(group.cost_est), _USAGE_COST_PLACES),
1145
+ "cost_total": round(float(group.cost_total), _USAGE_COST_PLACES),
1146
+ # Beyond `columns` — the contract explicitly allows extra row keys,
1147
+ # and a client that wants the cache split or the open-leg count can
1148
+ # have it without another column in an already-wide table.
1149
+ "tokens_cache_read": int(group.tokens.cache_read),
1150
+ "tokens_cache_creation": int(group.tokens.cache_creation),
1151
+ "duration_secs": round(float(group.duration_secs), 3),
1152
+ "open_legs": int(group.open_legs),
1153
+ "unknown_model_legs": int(group.unknown_model_legs),
1154
+ }
1155
+
1156
+
1157
+ def _usage_stage_breakdown(
1158
+ leg_rows: Sequence[Mapping[str, Any]], window: Any, pricing: Any
1159
+ ) -> list[dict[str, Any]]:
1160
+ """Per-stage sub-rollup for one group, as a list of plain dicts.
1161
+
1162
+ The panel's only drill-down was "click a row → its per-stage legs"; that
1163
+ maps onto rows without needing a second request, so it ships inline as an
1164
+ extra row key rather than as a second report.
1165
+ """
1166
+ from coord.usage_rollup import rollup_by_stage # noqa: PLC0415
1167
+
1168
+ sub = rollup_by_stage(list(leg_rows), window, pricing)
1169
+ stages = [
1170
+ {"stage": str(key), **_usage_metrics(grp)} for key, grp in sub.groups.items()
1171
+ ]
1172
+ stages.sort(key=lambda s: s["cost_total"], reverse=True)
1173
+ return stages
1174
+
1175
+
1176
+ def fold_usage(
1177
+ rows: Iterable[Mapping[str, Any]],
1178
+ window: Any,
1179
+ *,
1180
+ group_by: str = "issue",
1181
+ pricing: Any = None,
1182
+ generated_at: float | None = None,
1183
+ extra_notes: Sequence[str] = (),
1184
+ ) -> ReportResult:
1185
+ """Fold board assignment rows into a per-issue / per-repo cost rollup.
1186
+
1187
+ **Pure** — no DB, no daemon, no clock: *rows* is whatever the caller
1188
+ fetched (daemon ``/board`` ``assignments`` wire shape), *window* is a
1189
+ resolved :class:`~coord.usage_rollup.TimeWindow`, and *pricing* is the
1190
+ :class:`~coord.config.PricingConfig` that was actually loaded. Every
1191
+ number comes back out of :func:`coord.usage_rollup.rollup` — this
1192
+ function only shapes it into the report wire contract.
1193
+
1194
+ *pricing* left at ``None`` falls through to ``usage_rollup``'s own
1195
+ built-in defaults, which is correct for a unit test and **not** what the
1196
+ runner does (see :func:`run_usage`, which loads ``coordinator.yml``).
1197
+ """
1198
+ from coord.usage_rollup import IssueKey, rollup # noqa: PLC0415
1199
+
1200
+ columns = usage_columns(group_by)
1201
+ rows = list(rows)
1202
+ result = rollup(rows, group_by=group_by, window=window, pricing=pricing)
1203
+
1204
+ out_rows: list[dict[str, Any]] = []
1205
+ for key, group in result.groups.items():
1206
+ row: dict[str, Any] = {}
1207
+ if isinstance(key, IssueKey):
1208
+ row["issue"] = int(key.issue_number)
1209
+ row["repo"] = str(key.repo_name)
1210
+ row["title"] = _usage_row_title(group.leg_rows)
1211
+ else:
1212
+ row["repo"] = str(key)
1213
+ row.update(_usage_metrics(group))
1214
+ row["stages"] = _usage_stage_breakdown(group.leg_rows, window, pricing)
1215
+ out_rows.append(row)
1216
+
1217
+ # Same default order as `coord usage` and the retired panel: biggest
1218
+ # spend first. `_ident` breaks ties deterministically so a frozen-clock
1219
+ # test isn't at the mercy of dict ordering.
1220
+ out_rows.sort(
1221
+ key=lambda r: (-r["cost_total"], str(r.get("repo") or ""), int(r.get("issue") or 0))
1222
+ )
1223
+
1224
+ start = 0.0 if getattr(window, "start", None) is None else float(window.start)
1225
+ end = (
1226
+ float(generated_at if generated_at is not None else start)
1227
+ if getattr(window, "end", None) is None
1228
+ else float(window.end)
1229
+ )
1230
+
1231
+ totals = _usage_metrics(result.total)
1232
+
1233
+ notes: list[str] = list(extra_notes)
1234
+ if not out_rows:
1235
+ notes.append("No usage recorded in this window.")
1236
+ for row in out_rows:
1237
+ unknown = int(row.get("unknown_model_legs") or 0)
1238
+ if unknown:
1239
+ ident = (
1240
+ f"{row['repo']}#{row['issue']}" if "issue" in row else str(row["repo"])
1241
+ )
1242
+ notes.append(
1243
+ f"{ident}: {unknown} leg(s) ran a model with no entry in the "
1244
+ "loaded `pricing:` config — their tokens are counted but "
1245
+ "their spend is NOT in `cost_est` (never silently priced at "
1246
+ "$0). Add a rate for that model to coordinator.yml."
1247
+ )
1248
+ if totals["open_legs"]:
1249
+ notes.append(
1250
+ f"{totals['open_legs']} leg(s) in this window are still running — "
1251
+ "their duration counts as 0 and their cost is not final."
1252
+ )
1253
+
1254
+ return ReportResult(
1255
+ report_id="usage",
1256
+ generated_at=end if generated_at is None else float(generated_at),
1257
+ window=(start, end),
1258
+ columns=columns,
1259
+ column_meta=[_USAGE_COLUMN_META[c] for c in columns],
1260
+ rows=out_rows,
1261
+ notes=notes,
1262
+ totals=totals,
1263
+ )
1264
+
1265
+
1266
+ def _default_usage_rows(repo: str | None) -> list[dict]: # noqa: ARG001
1267
+ """Board assignment rows from the local DB.
1268
+
1269
+ Deliberately **not** :func:`coord.usage.fetch_usage_rows`: that helper
1270
+ branches to a ``GET /board`` when a board service is configured, and a
1271
+ report already runs *on* the daemon host (``coord.state.run_report``
1272
+ routes a thin client's request to ``GET /report/{id}``), so going through
1273
+ it would make the daemon HTTP-call itself. This mirrors that helper's
1274
+ *local* branch exactly — ``list_assignments()`` rather than the
1275
+ retention-capped ``/board`` projection, because a usage rollup wants full
1276
+ history.
1277
+ """
1278
+ from coord.dao import SqliteStore # noqa: PLC0415
1279
+
1280
+ return SqliteStore().list_assignments()
1281
+
1282
+
1283
+ def _load_pricing() -> tuple[Any, list[str]]:
1284
+ """The ``pricing:`` block from the loaded ``coordinator.yml``.
1285
+
1286
+ Returns ``(PricingConfig, notes)``. A config that cannot be loaded falls
1287
+ back to the built-in defaults **and says so in ``notes``** — silently
1288
+ falling back is exactly the failure mode #1763 exists to remove.
1289
+ """
1290
+ from coord.config import PricingConfig # noqa: PLC0415
1291
+
1292
+ try:
1293
+ from coord.config import load, resolve_config_path # noqa: PLC0415
1294
+
1295
+ return load(resolve_config_path()).pricing, []
1296
+ except Exception as exc: # noqa: BLE001 — surfaced as a note, not a crash
1297
+ return (
1298
+ PricingConfig(),
1299
+ [
1300
+ "WARNING: coordinator.yml could not be loaded "
1301
+ f"({type(exc).__name__}: {exc}) — `cost_est` uses the built-in "
1302
+ "default rates, which may differ from this fleet's `pricing:` "
1303
+ "block."
1304
+ ],
1305
+ )
1306
+
1307
+
1308
+ def run_usage(
1309
+ *,
1310
+ window: str = "today",
1311
+ group_by: str = "issue",
1312
+ repo: str = "",
1313
+ now: float | None = None,
1314
+ fetch: Callable[[str | None], Sequence[Mapping[str, Any]]] | None = None,
1315
+ pricing: Any = None,
1316
+ ) -> ReportResult:
1317
+ """Fetch board rows and fold them. ``now``/``fetch``/``pricing`` are test
1318
+ seams; the report's own parameters are ``window``/``group_by``/``repo``."""
1319
+ generated_at = time.time() if now is None else float(now)
1320
+ resolved = resolve_usage_window(window, generated_at)
1321
+
1322
+ fetch_fn = _default_usage_rows if fetch is None else fetch
1323
+ rows = list(fetch_fn(repo or None) or [])
1324
+ if repo:
1325
+ rows = [r for r in rows if str(r.get("repo_name") or "") == repo]
1326
+
1327
+ extra_notes: list[str] = []
1328
+ if pricing is None:
1329
+ pricing, extra_notes = _load_pricing()
1330
+
1331
+ return fold_usage(
1332
+ rows,
1333
+ resolved,
1334
+ group_by=group_by,
1335
+ pricing=pricing,
1336
+ generated_at=generated_at,
1337
+ extra_notes=extra_notes,
1338
+ )
1339
+
1340
+
1341
+ # ── the catalogue ──────────────────────────────────────────────────────────
1342
+
1343
+ SINCE_PRESETS = ("1h", "6h", "24h", "3d", "7d")
1344
+
1345
+
1346
+ def _validate_since(value: str) -> None:
1347
+ if value in SINCE_PRESETS:
1348
+ return
1349
+ try:
1350
+ parse_duration(value)
1351
+ except ReportError as exc:
1352
+ raise ReportError(
1353
+ f"invalid value for 'since': {value!r} — allowed values: "
1354
+ f"{', '.join(SINCE_PRESETS)}, or any duration like '13h' "
1355
+ "(units: s, m, h, d, w)"
1356
+ ) from exc
1357
+
1358
+
1359
+ def _validate_until(value: str) -> None:
1360
+ if not value:
1361
+ return
1362
+ try:
1363
+ parse_timestamp(value)
1364
+ except ReportError as exc:
1365
+ raise ReportError(
1366
+ f"invalid value for 'until': {value!r} — expected epoch seconds "
1367
+ "or an ISO-8601 timestamp (e.g. '2026-08-03T09:16:00Z'), or "
1368
+ "empty for 'now'"
1369
+ ) from exc
1370
+
1371
+
1372
+ ISSUE_ACTIVITY = ReportDef(
1373
+ id="issue-activity",
1374
+ title="Issue Activity",
1375
+ description=(
1376
+ "What moved in this window and where did it end up — the audit trail "
1377
+ "folded into one row per issue: when it started, which machines "
1378
+ "touched it, how many fix iterations it took, its Test/Review "
1379
+ "verdicts in order, whether it merged, and how its driver exited."
1380
+ ),
1381
+ params=(
1382
+ ReportParam(
1383
+ id="since",
1384
+ label="Time range",
1385
+ kind="choice",
1386
+ choices=SINCE_PRESETS,
1387
+ default="24h",
1388
+ help="How far back the window reaches from `until`. Presets, or any duration (e.g. 13h).",
1389
+ free_form=True,
1390
+ validate=_validate_since,
1391
+ ),
1392
+ ReportParam(
1393
+ id="until",
1394
+ label="Window end",
1395
+ kind="text",
1396
+ default="",
1397
+ help="Epoch seconds or ISO-8601. Empty means now.",
1398
+ validate=_validate_until,
1399
+ ),
1400
+ ReportParam(
1401
+ id="repo",
1402
+ label="Repo",
1403
+ kind="text",
1404
+ default="",
1405
+ help="Restrict to one repo by name. Empty means all repos.",
1406
+ ),
1407
+ ),
1408
+ run=run_issue_activity,
1409
+ )
1410
+
1411
+
1412
+ DRIVE_QUEUE_STATUS = ReportDef(
1413
+ id="drive-queue-status",
1414
+ title="Drive Queue Status",
1415
+ description=(
1416
+ "A live snapshot of the drive queue — one row per queued entry in "
1417
+ "run order, with its state, machine pin, attempts/deferrals and the "
1418
+ "tick's own last_reason. A snapshot, not a history: `drive_queue` "
1419
+ "has no `completed_at`, so this shows what is queued now, not what "
1420
+ "the queue has processed."
1421
+ ),
1422
+ params=(
1423
+ ReportParam(
1424
+ id="repo",
1425
+ label="Repo",
1426
+ kind="text",
1427
+ default="",
1428
+ help="Restrict to one repo by name. Empty means all repos.",
1429
+ ),
1430
+ ),
1431
+ run=run_drive_queue_status,
1432
+ )
1433
+
1434
+
1435
+ USAGE = ReportDef(
1436
+ id="usage",
1437
+ title="Usage",
1438
+ description=(
1439
+ "Cost and token spend for a time window, one row per issue (or per "
1440
+ "repo): legs, tokens in/out, captured $, estimated ~$ for legs with "
1441
+ "no captured cost, and the total. Estimates use the daemon's own "
1442
+ "loaded `pricing:` block, so they agree with `coord usage` by "
1443
+ "construction."
1444
+ ),
1445
+ params=(
1446
+ ReportParam(
1447
+ id="window",
1448
+ label="Time window",
1449
+ kind="choice",
1450
+ choices=USAGE_WINDOW_CHOICES,
1451
+ default="today",
1452
+ help=(
1453
+ "today/week/month are local calendar periods; 7d/30d are "
1454
+ "rolling windows ending now."
1455
+ ),
1456
+ ),
1457
+ ReportParam(
1458
+ id="group_by",
1459
+ label="Group by",
1460
+ kind="choice",
1461
+ choices=USAGE_GROUP_BY_CHOICES,
1462
+ default="issue",
1463
+ help="One row per issue, or one row per repo.",
1464
+ ),
1465
+ ReportParam(
1466
+ id="repo",
1467
+ label="Repo",
1468
+ kind="text",
1469
+ default="",
1470
+ help="Restrict to one repo by name. Empty means all repos.",
1471
+ ),
1472
+ ),
1473
+ run=run_usage,
1474
+ )
1475
+
1476
+
1477
+ # ── CSV serialisation (#1765) ──────────────────────────────────────────────
1478
+ #
1479
+ # One serializer, server-side, for every surface: `coord report run --format
1480
+ # csv`, `GET /report/{id}?format=csv`, and the coord-tui Reports panel's
1481
+ # Export action (which fetches the route rather than formatting anything
1482
+ # itself). Doing it here is not incidental — the values on the wire are
1483
+ # **raw** (`started_at` is an epoch float, `machines` is a list), and every
1484
+ # renderer turns those into display strings (`13h ago`, `dellserver,
1485
+ # precision`). A client-side CSV would therefore export the *formatting*,
1486
+ # not the data: an epoch would become a relative string no spreadsheet can
1487
+ # sort, and the bytes would silently depend on when Export was clicked.
1488
+ #
1489
+ # Line terminator is `\n`, not RFC 4180's `\r\n`: this is a Unix tool whose
1490
+ # output is piped and redirected, `csv.reader` accepts either, and every
1491
+ # spreadsheet we care about does too. Fixing it (rather than taking
1492
+ # `csv.writer`'s platform-ish default) is what makes CLI and daemon bytes
1493
+ # identical.
1494
+ _CSV_LINE_TERMINATOR = "\n"
1495
+
1496
+
1497
+ def _csv_scalar(value: Any) -> str:
1498
+ """One *raw* value → its CSV text. Never a display string."""
1499
+ if value is None:
1500
+ return ""
1501
+ if isinstance(value, bool):
1502
+ # Before the int check — bool is an int in Python, and `true`/`false`
1503
+ # is what every consumer of this file expects to see.
1504
+ return "true" if value else "false"
1505
+ return str(value)
1506
+
1507
+
1508
+ def _csv_cell(value: Any) -> str:
1509
+ """One row value → one CSV field.
1510
+
1511
+ Composite values collapse into a single field rather than spilling into
1512
+ extra columns: lists (``machines``, ``test_verdicts``) join with ``"; "``,
1513
+ and dicts (``drive_exit``) render as ``key=value`` pairs joined the same
1514
+ way. ``drive_exit.reason`` is embedded **verbatim** — commas, quotes and
1515
+ newlines and all — because `csv.writer` quotes and escapes it, and a
1516
+ round-trip through `csv.reader` has to return the original text (#1631's
1517
+ multi-line driver-exit reason is the regression fixture). JSON-encoding
1518
+ the dict would have escaped that newline into a literal ``\\n`` and lost
1519
+ the round-trip.
1520
+ """
1521
+ if isinstance(value, (list, tuple)):
1522
+ return "; ".join(_csv_scalar(v) for v in value)
1523
+ if isinstance(value, Mapping):
1524
+ return "; ".join(f"{k}={_csv_scalar(v)}" for k, v in value.items())
1525
+ return _csv_scalar(value)
1526
+
1527
+
1528
+ def _csv_comment(text: str) -> list[str]:
1529
+ """A note → its ``#``-prefixed line(s). A note that itself spans lines
1530
+ gets one ``#`` per physical line, so no fragment can escape into the
1531
+ data and be parsed as a row."""
1532
+ lines = str(text).splitlines() or [""]
1533
+ return [f"# {line}" if line else "#" for line in lines]
1534
+
1535
+
1536
+ def result_to_csv(result: "ReportResult | Mapping[str, Any]") -> str:
1537
+ """Serialise a :class:`ReportResult` (or its ``to_dict()`` form) as CSV.
1538
+
1539
+ Shape:
1540
+
1541
+ * leading ``#``-prefixed comment lines — the report id, the window, and
1542
+ **every** ``notes`` entry. Notes are the derived anomalies and are the
1543
+ most valuable part of ``issue-activity``; they are not rows, and they
1544
+ must never silently vanish, so they ride along as comments that keep
1545
+ the file self-describing and still let it parse once ``#`` lines are
1546
+ skipped.
1547
+ * a header row, labelled from ``column_meta[].label`` (#1760) when
1548
+ present and from the raw column key otherwise.
1549
+ * one row per ``rows`` entry, raw values only.
1550
+ * ``totals`` (#1763), when the report has one, as a final row — flagged
1551
+ in the comments so nobody mistakes it for another data row. Reports
1552
+ without a meaningful sum emit no such row and are unaffected.
1553
+ """
1554
+ data = result.to_dict() if isinstance(result, ReportResult) else dict(result)
1555
+
1556
+ columns = [str(c) for c in (data.get("columns") or [])]
1557
+ labels = {
1558
+ str(m.get("id")): str(m.get("label") or m.get("id"))
1559
+ for m in (data.get("column_meta") or [])
1560
+ if isinstance(m, Mapping)
1561
+ }
1562
+ window = data.get("window") or [None, None]
1563
+
1564
+ comments: list[str] = [
1565
+ f"# report: {data.get('report_id')}",
1566
+ f"# window: {_iso(window[0])} to {_iso(window[1])}",
1567
+ f"# generated: {_iso(data.get('generated_at'))}",
1568
+ ]
1569
+ rows = list(data.get("rows") or [])
1570
+ comments.append(f"# rows: {len(rows)}")
1571
+ # The export is the report's own canonical row order — never a client's
1572
+ # transient sort (#1762), which is view state over one result set.
1573
+ comments.append("# order: the report's canonical row order")
1574
+ totals = data.get("totals")
1575
+ if isinstance(totals, Mapping):
1576
+ comments.append(
1577
+ "# totals: the final row is the grand total, not a data row "
1578
+ "(identity columns are blank)"
1579
+ )
1580
+ for note in data.get("notes") or []:
1581
+ comments.extend(_csv_comment(note))
1582
+
1583
+ buf = io.StringIO()
1584
+ writer = csv.writer(buf, lineterminator=_CSV_LINE_TERMINATOR)
1585
+ writer.writerow([labels.get(c, c) for c in columns])
1586
+ for row in rows:
1587
+ row = row if isinstance(row, Mapping) else {}
1588
+ writer.writerow([_csv_cell(row.get(c)) for c in columns])
1589
+ if isinstance(totals, Mapping):
1590
+ writer.writerow([_csv_cell(totals.get(c)) for c in columns])
1591
+
1592
+ header = "".join(line + _CSV_LINE_TERMINATOR for line in comments)
1593
+ return header + buf.getvalue()
1594
+
1595
+
1596
+ def csv_filename(result: "ReportResult | Mapping[str, Any]") -> str:
1597
+ """``issue-activity-20260804-1130.csv`` — the suggested download name.
1598
+
1599
+ Derived from the *result* (its window end), not from the wall clock, so
1600
+ the daemon's ``Content-Disposition`` and the panel's save-dialog
1601
+ suggestion agree for the same run.
1602
+ """
1603
+ data = result.to_dict() if isinstance(result, ReportResult) else dict(result)
1604
+ window = data.get("window") or [None, None]
1605
+ stamp_at = window[1] if window[1] is not None else data.get("generated_at")
1606
+ try:
1607
+ stamp = datetime.fromtimestamp(float(stamp_at), tz=timezone.utc).strftime(
1608
+ "%Y%m%d-%H%M"
1609
+ )
1610
+ except (TypeError, ValueError):
1611
+ stamp = "unknown"
1612
+ report_id = re.sub(r"[^A-Za-z0-9._-]+", "-", str(data.get("report_id") or "report"))
1613
+ return f"{report_id}-{stamp}.csv"
1614
+
1615
+
1616
+ REPORTS: dict[str, ReportDef] = {
1617
+ ISSUE_ACTIVITY.id: ISSUE_ACTIVITY,
1618
+ DRIVE_QUEUE_STATUS.id: DRIVE_QUEUE_STATUS,
1619
+ USAGE.id: USAGE,
1620
+ }
1621
+
1622
+
1623
+ def catalogue() -> dict[str, Any]:
1624
+ """The wire shape of ``GET /report`` — everything #1741 needs to build a
1625
+ report picker and its parameter form without hardcoding anything."""
1626
+ return {"reports": [REPORTS[rid].to_dict() for rid in sorted(REPORTS)]}
1627
+
1628
+
1629
+ def run_report(
1630
+ report_id: str,
1631
+ params: Mapping[str, Any] | None = None,
1632
+ **injected: Any,
1633
+ ) -> ReportResult:
1634
+ """Look up, validate, run. Raises :class:`UnknownReportError` /
1635
+ :class:`ReportError` — never a traceback for a bad request."""
1636
+ report = REPORTS.get(report_id)
1637
+ if report is None:
1638
+ raise UnknownReportError(
1639
+ f"unknown report {report_id!r} — known reports: "
1640
+ f"{', '.join(sorted(REPORTS))}"
1641
+ )
1642
+ resolved = resolve_params(report, params)
1643
+ return report.run(**resolved, **injected)