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/usage_rollup.py ADDED
@@ -0,0 +1,709 @@
1
+ """Usage Core (#1118): pure cost/token/duration aggregation over board rows.
2
+
3
+ This module is a **pure** aggregator — every function here takes plain data
4
+ in (board assignment rows as ``dict``s, matching the daemon ``/board``
5
+ ``assignments`` wire shape) and returns plain data out. No I/O, no daemon
6
+ calls, no filesystem reads. That separation is deliberate: it's the anchor of
7
+ the Gate-A acceptance contract (``tests/acceptance/ms-37/contract.md``) —
8
+ seeded rows in, an exact rollup out — and it's what lets CLI-1 (#1115),
9
+ CLI-2 (#1119), and the TUI view (#1116) all share one aggregation path
10
+ instead of three reimplementations. The *fetch* side (reading the daemon
11
+ board via ``coord.board_service`` / ``resolve_board_service``, the same read
12
+ path ``coord status`` uses) is a thin, separate caller — see
13
+ ``coord.usage.fetch_usage_rows`` — deliberately kept out of this module.
14
+
15
+ Row fields consumed (all optional except where noted; missing/None fields
16
+ degrade gracefully rather than raising): ``issue_number``, ``issue_title``,
17
+ ``repo_name``, ``type``, ``model``, ``is_interactive``, ``status``,
18
+ ``cost_usd``, ``input_tokens``, ``output_tokens``, ``cache_read_tokens``,
19
+ ``cache_creation_tokens``, ``dispatched_at``, ``finished_at``.
20
+
21
+ Grouping dimensions (``group_by=``): ``"issue"`` (keyed by repo+issue number
22
+ in the internal :func:`rollup` path — see :func:`_agg_key` for how the
23
+ sealed :func:`aggregate` entry point differs), ``"repo"``, ``"day"``,
24
+ ``"week"`` (ISO week), ``"month"``, and ``"stage"`` (keyed by ``type`` — the
25
+ per-stage-type sub-rollup for the time view, see :func:`rollup_by_stage`).
26
+
27
+ Time window: a resolved half-open ``[start, end)`` interval (see
28
+ :class:`TimeWindow`). ``today``/``week``/``month``/``since=<ISO|Nd|Nh>`` are
29
+ *presets* that compute one; :class:`TimeWindow` itself is the general case
30
+ (explicit ``start``/``end``) so callers like the TUI's arbitrary range picker
31
+ (#1116) aren't limited to the presets. A leg is in-window if its
32
+ ``dispatched_at`` **or** ``finished_at`` falls in the interval — a leg that
33
+ started before the window but finished inside it (or vice versa) still
34
+ counts.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ from dataclasses import dataclass, field
41
+ from datetime import datetime, timedelta
42
+ from typing import Any, Iterable, NamedTuple
43
+
44
+ from coord.config import ModelRates, PricingConfig
45
+
46
+ # ── Model normalization ──────────────────────────────────────────────────────
47
+
48
+ # Canonical keys this module recognizes out of the box. Anything else is
49
+ # "(unknown)" — never guessed, never silently priced at $0.
50
+ _KNOWN_CANONICAL = ("sonnet", "opus", "haiku", "fable")
51
+
52
+ UNKNOWN_MODEL = "(unknown)"
53
+
54
+
55
+ def normalize_model(model: str | None) -> str:
56
+ """Normalize a raw ``model`` field to a canonical pricing key.
57
+
58
+ Handles bare aliases (``"sonnet"``, ``"opus"``, ``"haiku"``, ``"fable"``),
59
+ versioned ids (``"claude-sonnet-4-6"``, ``"claude-opus-4-7"``,
60
+ ``"claude-haiku-4-5"``, ``"claude-fable-5"``, and future dated variants —
61
+ matched by substring so a new date suffix doesn't need a code change),
62
+ and the empty/``None``/``"(unknown)"`` cases. Anything that doesn't match
63
+ one of the four known tiers returns ``"(unknown)"`` — the estimator
64
+ treats that as "no rate available" and flags it, rather than defaulting
65
+ to a tier that might be wrong.
66
+ """
67
+ if not model:
68
+ return UNKNOWN_MODEL
69
+ text = str(model).strip().lower()
70
+ if not text or text == UNKNOWN_MODEL:
71
+ return UNKNOWN_MODEL
72
+ if text in _KNOWN_CANONICAL:
73
+ return text
74
+ for canonical in _KNOWN_CANONICAL:
75
+ if canonical in text:
76
+ return canonical
77
+ return UNKNOWN_MODEL
78
+
79
+
80
+ # ── Timestamp parsing ─────────────────────────────────────────────────────────
81
+
82
+
83
+ def parse_timestamp(value: Any) -> float | None:
84
+ """Parse a row timestamp field to a Unix float.
85
+
86
+ Accepts a Unix float/int directly, an ISO-8601 string (``datetime.
87
+ fromisoformat``, tolerating a trailing ``Z``), or a numeric string.
88
+ Returns ``None`` for ``None``, empty strings, and unparseable values —
89
+ callers treat "no timestamp" as "not in any window" / "not orderable"
90
+ rather than raising.
91
+ """
92
+ if value is None:
93
+ return None
94
+ if isinstance(value, bool):
95
+ return None
96
+ if isinstance(value, (int, float)):
97
+ return float(value)
98
+ if isinstance(value, str):
99
+ text = value.strip()
100
+ if not text:
101
+ return None
102
+ try:
103
+ return float(text)
104
+ except ValueError:
105
+ pass
106
+ try:
107
+ iso = text[:-1] + "+00:00" if text.endswith("Z") else text
108
+ return datetime.fromisoformat(iso).timestamp()
109
+ except ValueError:
110
+ return None
111
+ return None
112
+
113
+
114
+ def _to_int(value: Any) -> int:
115
+ if value is None:
116
+ return 0
117
+ try:
118
+ return int(value)
119
+ except (TypeError, ValueError):
120
+ return 0
121
+
122
+
123
+ # ── Time window ───────────────────────────────────────────────────────────────
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class TimeWindow:
128
+ """A resolved half-open ``[start, end)`` interval, in Unix time.
129
+
130
+ This is the general case: pass explicit ``start``/``end`` (either may be
131
+ ``None`` for an unbounded side) for an arbitrary range — e.g. the TUI
132
+ range picker (#1116), or a CLI ``--since/--until`` pair. The
133
+ ``today``/``week``/``month``/``since`` module-level functions below are
134
+ *presets* that compute one of these; they are not separate modes.
135
+
136
+ ``label`` is a human-readable rendering of the resolved range (e.g.
137
+ ``"today"`` or ``"2026-07-01 00:00 -> 2026-07-08 00:00"``) for the
138
+ surfaces that print a window header — purely descriptive, not consumed
139
+ by any predicate here.
140
+ """
141
+
142
+ start: float | None = None
143
+ end: float | None = None
144
+ label: str = ""
145
+
146
+ def contains(self, ts: float | None) -> bool:
147
+ """Whether *ts* falls in ``[start, end)``. ``None`` is never in-window."""
148
+ if ts is None:
149
+ return False
150
+ if self.start is not None and ts < self.start:
151
+ return False
152
+ if self.end is not None and ts >= self.end:
153
+ return False
154
+ return True
155
+
156
+
157
+ def _local_day_bounds(dt: datetime) -> tuple[datetime, datetime]:
158
+ start = dt.replace(hour=0, minute=0, second=0, microsecond=0)
159
+ return start, start + timedelta(days=1)
160
+
161
+
162
+ def _resolve_now(now: float | None) -> datetime:
163
+ return datetime.fromtimestamp(now) if now is not None else datetime.now()
164
+
165
+
166
+ def window_today(now: float | None = None) -> TimeWindow:
167
+ """Preset: local calendar day containing *now* (or the real "now")."""
168
+ start, end = _local_day_bounds(_resolve_now(now))
169
+ return TimeWindow(start=start.timestamp(), end=end.timestamp(), label="today")
170
+
171
+
172
+ def window_week(now: float | None = None) -> TimeWindow:
173
+ """Preset: current ISO week (Monday 00:00 local -> next Monday 00:00)."""
174
+ day_start, _ = _local_day_bounds(_resolve_now(now))
175
+ monday = day_start - timedelta(days=day_start.weekday())
176
+ return TimeWindow(
177
+ start=monday.timestamp(), end=(monday + timedelta(days=7)).timestamp(), label="week"
178
+ )
179
+
180
+
181
+ def window_month(now: float | None = None) -> TimeWindow:
182
+ """Preset: current calendar month (1st 00:00 local -> 1st of next month)."""
183
+ dt = _resolve_now(now)
184
+ start = dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
185
+ end = (
186
+ start.replace(year=start.year + 1, month=1)
187
+ if start.month == 12
188
+ else start.replace(month=start.month + 1)
189
+ )
190
+ return TimeWindow(start=start.timestamp(), end=end.timestamp(), label="month")
191
+
192
+
193
+ _SINCE_RELATIVE_RE = re.compile(r"^(\d+)\s*([dh])$", re.IGNORECASE)
194
+
195
+
196
+ def window_since(spec: str, now: float | None = None) -> TimeWindow:
197
+ """Preset: open-ended window starting at *spec* (``<ISO>``, ``Nd``, or ``Nh``).
198
+
199
+ ``end`` is left unbounded (``None``) — everything from ``start`` onward
200
+ is in-window, including legs that haven't finished yet.
201
+ """
202
+ now_dt = _resolve_now(now)
203
+ match = _SINCE_RELATIVE_RE.match(spec.strip())
204
+ if match:
205
+ amount = int(match.group(1))
206
+ unit = match.group(2).lower()
207
+ delta = timedelta(days=amount) if unit == "d" else timedelta(hours=amount)
208
+ start_dt = now_dt - delta
209
+ else:
210
+ parsed = parse_timestamp(spec)
211
+ if parsed is None:
212
+ raise ValueError(f"invalid 'since' spec: {spec!r} (expected ISO date, 'Nd', or 'Nh')")
213
+ start_dt = datetime.fromtimestamp(parsed)
214
+ return TimeWindow(start=start_dt.timestamp(), end=None, label=f"since {spec}")
215
+
216
+
217
+ def leg_in_window(row: dict, window: TimeWindow) -> bool:
218
+ """Whether *row* is in-window: ``dispatched_at`` OR ``finished_at`` in range."""
219
+ dispatched = parse_timestamp(row.get("dispatched_at"))
220
+ finished = parse_timestamp(row.get("finished_at"))
221
+ return window.contains(dispatched) or window.contains(finished)
222
+
223
+
224
+ # ── Per-leg cost + duration ──────────────────────────────────────────────────
225
+
226
+
227
+ def leg_cost(row: dict, pricing: PricingConfig) -> tuple[float, float, bool]:
228
+ """Compute ``(cost_captured, cost_est, unknown_model)`` for one leg.
229
+
230
+ A leg with a real captured ``cost_usd`` (non-null, non-zero) keeps it
231
+ verbatim as ``cost_captured`` and never also gets an estimate (no
232
+ double-counting). A leg with ``cost_usd`` in ``{None, 0}`` **and** any
233
+ tokens gets an estimate from *pricing*, keyed by the leg's normalized
234
+ model — unless the model doesn't map to a priced tier, in which case no
235
+ estimate is produced and ``unknown_model`` is ``True`` (never a silent
236
+ $0). A leg with no tokens and no captured cost is simply zero everywhere.
237
+ """
238
+ raw_cost = row.get("cost_usd")
239
+ try:
240
+ captured = float(raw_cost) if raw_cost not in (None, "") else 0.0
241
+ except (TypeError, ValueError):
242
+ captured = 0.0
243
+ if captured:
244
+ return captured, 0.0, False
245
+
246
+ input_tokens = _to_int(row.get("input_tokens"))
247
+ output_tokens = _to_int(row.get("output_tokens"))
248
+ cache_read_tokens = _to_int(row.get("cache_read_tokens"))
249
+ cache_creation_tokens = _to_int(row.get("cache_creation_tokens"))
250
+ total_tokens = input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens
251
+ if total_tokens <= 0:
252
+ return 0.0, 0.0, False
253
+
254
+ canonical = normalize_model(row.get("model"))
255
+ rates: ModelRates | None = pricing.rates_for(canonical)
256
+ if rates is None:
257
+ return 0.0, 0.0, True
258
+
259
+ est = (
260
+ input_tokens * rates.input
261
+ + output_tokens * rates.output
262
+ + cache_read_tokens * rates.cache_read
263
+ + cache_creation_tokens * rates.cache_creation
264
+ ) / 1_000_000.0
265
+ return 0.0, est, False
266
+
267
+
268
+ def leg_duration(row: dict) -> tuple[float, bool]:
269
+ """Compute ``(duration_secs, is_open)`` for one leg.
270
+
271
+ ``is_open`` is ``True`` when there's no ``finished_at`` (still running):
272
+ duration contributes ``0`` and the leg is counted separately so the time
273
+ view can note "N in progress." Otherwise duration is
274
+ ``max(0, finished_at - dispatched_at)`` — clamped so a clock skew or bad
275
+ data pair never goes negative.
276
+ """
277
+ finished = parse_timestamp(row.get("finished_at"))
278
+ if finished is None:
279
+ return 0.0, True
280
+ dispatched = parse_timestamp(row.get("dispatched_at"))
281
+ if dispatched is None:
282
+ return 0.0, False
283
+ return max(0.0, finished - dispatched), False
284
+
285
+
286
+ # ── Grouping ──────────────────────────────────────────────────────────────────
287
+
288
+
289
+ class IssueKey(NamedTuple):
290
+ """Group key for ``group_by="issue"`` — an issue is scoped to its repo."""
291
+
292
+ repo_name: str
293
+ issue_number: int
294
+
295
+
296
+ _VALID_GROUP_BY = ("issue", "repo", "day", "week", "month", "stage")
297
+
298
+
299
+ def _leg_group_timestamp(row: dict) -> float | None:
300
+ """Timestamp used to bucket a leg into a day/week/month group.
301
+
302
+ Prefers ``dispatched_at`` (when the work started) and falls back to
303
+ ``finished_at`` so a leg with only one of the two timestamps still
304
+ lands somewhere instead of being silently dropped from time-based
305
+ grouping.
306
+ """
307
+ ts = parse_timestamp(row.get("dispatched_at"))
308
+ if ts is None:
309
+ ts = parse_timestamp(row.get("finished_at"))
310
+ return ts
311
+
312
+
313
+ def row_issue_number(row: dict) -> int:
314
+ """The issue a leg's cost is attributed to (#1553).
315
+
316
+ ``for_issue_number`` when the row carries one (an oracle-loop acceptance
317
+ slice and everything derived from it — its ``issue_number`` is the
318
+ milestone's *tracking* issue, so grouping on that booked every child's
319
+ authoring spend to the epic), else ``issue_number``. Rows without the
320
+ key — including the sealed ms-37 fixtures — are unaffected.
321
+
322
+ Mirrors :func:`coord.models.effective_issue_number`, kept local so this
323
+ module stays a pure dict-in/dict-out aggregator with no model import.
324
+ """
325
+ for key in ("for_issue_number", "issue_number"):
326
+ raw = row.get(key)
327
+ if raw is None or raw == "":
328
+ continue
329
+ return _to_int(raw)
330
+ return 0
331
+
332
+
333
+ def _group_key_for(row: dict, group_by: str) -> Any:
334
+ if group_by == "issue":
335
+ return IssueKey(
336
+ repo_name=str(row.get("repo_name") or ""),
337
+ issue_number=row_issue_number(row),
338
+ )
339
+ if group_by == "repo":
340
+ return str(row.get("repo_name") or "")
341
+ if group_by == "stage":
342
+ return str(row.get("type") or "work")
343
+
344
+ ts = _leg_group_timestamp(row)
345
+ if ts is None:
346
+ return None
347
+ dt = datetime.fromtimestamp(ts)
348
+ if group_by == "day":
349
+ return dt.date().isoformat()
350
+ if group_by == "week":
351
+ iso_year, iso_week, _ = dt.isocalendar()
352
+ return f"{iso_year:04d}-W{iso_week:02d}"
353
+ if group_by == "month":
354
+ return f"{dt.year:04d}-{dt.month:02d}"
355
+ raise ValueError(f"unknown group_by: {group_by!r} (expected one of {_VALID_GROUP_BY})")
356
+
357
+
358
+ # ── Rollup result ─────────────────────────────────────────────────────────────
359
+
360
+
361
+ @dataclass
362
+ class TokenTotals:
363
+ """Token sums for one group or the overall total."""
364
+
365
+ input: int = 0
366
+ output: int = 0
367
+ cache_read: int = 0
368
+ cache_creation: int = 0
369
+
370
+
371
+ @dataclass
372
+ class GroupRollup:
373
+ """Aggregated cost/token/duration numbers for one group (or the grand total)."""
374
+
375
+ key: Any
376
+ legs: int = 0
377
+ cost_captured: float = 0.0
378
+ cost_est: float = 0.0
379
+ tokens: TokenTotals = field(default_factory=TokenTotals)
380
+ duration_secs: float = 0.0
381
+ open_legs: int = 0
382
+ unknown_model_legs: int = 0
383
+ # Retained per-leg rows for drill-down (e.g. CLI-1's `coord usage --issue N`
384
+ # per-stage breakdown). Rows appear in the order they were accumulated.
385
+ leg_rows: list[dict] = field(default_factory=list)
386
+
387
+ @property
388
+ def cost_total(self) -> float:
389
+ """Captured + estimated cost — never double-counts a leg (see :func:`leg_cost`)."""
390
+ return self.cost_captured + self.cost_est
391
+
392
+ @property
393
+ def has_unknown_model(self) -> bool:
394
+ return self.unknown_model_legs > 0
395
+
396
+
397
+ @dataclass
398
+ class RollupResult:
399
+ """The full result of :func:`rollup`: the resolved window, dimension, and groups."""
400
+
401
+ window: TimeWindow
402
+ group_by: str
403
+ groups: dict[Any, GroupRollup]
404
+ total: GroupRollup
405
+
406
+
407
+ def _accumulate(group: GroupRollup, row: dict, pricing: PricingConfig) -> None:
408
+ captured, est, unknown_model = leg_cost(row, pricing)
409
+ duration, is_open = leg_duration(row)
410
+
411
+ group.legs += 1
412
+ group.cost_captured += captured
413
+ group.cost_est += est
414
+ group.tokens.input += _to_int(row.get("input_tokens"))
415
+ group.tokens.output += _to_int(row.get("output_tokens"))
416
+ group.tokens.cache_read += _to_int(row.get("cache_read_tokens"))
417
+ group.tokens.cache_creation += _to_int(row.get("cache_creation_tokens"))
418
+ group.duration_secs += duration
419
+ if is_open:
420
+ group.open_legs += 1
421
+ if unknown_model:
422
+ group.unknown_model_legs += 1
423
+ group.leg_rows.append(row)
424
+
425
+
426
+ def rollup(
427
+ rows: Iterable[dict],
428
+ *,
429
+ group_by: str,
430
+ window: TimeWindow,
431
+ pricing: PricingConfig | None = None,
432
+ ) -> RollupResult:
433
+ """Aggregate *rows* into per-group cost/token/duration rollups.
434
+
435
+ Only rows in-window (see :func:`leg_in_window`) are counted at all — an
436
+ out-of-window leg contributes to nothing, including the grand total.
437
+ *pricing* defaults to the built-in :class:`~coord.config.PricingConfig`
438
+ defaults when omitted (callers that loaded ``coordinator.yml`` should
439
+ pass ``config.pricing`` instead).
440
+ """
441
+ if group_by not in _VALID_GROUP_BY:
442
+ raise ValueError(f"unknown group_by: {group_by!r} (expected one of {_VALID_GROUP_BY})")
443
+ if pricing is None:
444
+ pricing = PricingConfig()
445
+
446
+ groups: dict[Any, GroupRollup] = {}
447
+ total = GroupRollup(key=None)
448
+
449
+ for row in rows:
450
+ if not leg_in_window(row, window):
451
+ continue
452
+ key = _group_key_for(row, group_by)
453
+ if key is None:
454
+ # No orderable timestamp for a day/week/month bucket — excluded
455
+ # rather than silently lumped into a bogus "unknown" bucket.
456
+ continue
457
+ group = groups.setdefault(key, GroupRollup(key=key))
458
+ _accumulate(group, row, pricing)
459
+ _accumulate(total, row, pricing)
460
+
461
+ return RollupResult(window=window, group_by=group_by, groups=groups, total=total)
462
+
463
+
464
+ def rollup_by_stage(
465
+ rows: Iterable[dict],
466
+ window: TimeWindow,
467
+ pricing: PricingConfig | None = None,
468
+ ) -> RollupResult:
469
+ """Per-stage-type (work/smoke/review/conflict-fix/chat/test-author) rollup.
470
+
471
+ This is the "where is time spent" answer for the time view — a thin,
472
+ named alias for ``rollup(rows, group_by="stage", ...)`` so callers don't
473
+ need to know the dimension's string key.
474
+ """
475
+ return rollup(rows, group_by="stage", window=window, pricing=pricing)
476
+
477
+
478
+ # ── Public contract API (Gate-A / ms-37 acceptance surface) ───────────────────
479
+ # Stable, sealed names consumed by tests/acceptance/**, CLI-1 (#1115),
480
+ # CLI-2 (#1119), and TUI (#1116). The internal names above (TimeWindow,
481
+ # leg_cost, normalize_model, leg_in_window) remain unchanged so existing
482
+ # callers don't break.
483
+
484
+ canonical_model = normalize_model
485
+ """Alias for :func:`normalize_model` — public Gate-A name."""
486
+
487
+ in_window = leg_in_window
488
+ """Alias for :func:`leg_in_window` — public Gate-A name."""
489
+
490
+
491
+ @dataclass(frozen=True)
492
+ class Window(TimeWindow):
493
+ """Half-open ``[start, end)`` interval — public API alias for :class:`TimeWindow`.
494
+
495
+ ``Window(start, end)`` constructs a plain interval. The two class methods
496
+ below add preset constructors with *bounded* semantics — both ``start`` and
497
+ ``end`` are always set, which is why ``Window.since("2d")`` differs from the
498
+ module-level :func:`window_since` (which leaves ``end=None``).
499
+ """
500
+
501
+ @classmethod
502
+ def since(cls, spec: str, now: float | None = None) -> "Window":
503
+ """Half-open ``[start, now)`` window.
504
+
505
+ *spec* is a relative duration (``Nd`` / ``Nh``) or an ISO-8601 instant
506
+ for ``start``; ``end`` is always anchored to *now* (unlike the
507
+ module-level :func:`window_since` which leaves ``end`` unbounded).
508
+ """
509
+ dt_now = _resolve_now(now)
510
+ now_ts = dt_now.timestamp()
511
+ match = _SINCE_RELATIVE_RE.match(spec.strip())
512
+ if match:
513
+ amount = int(match.group(1))
514
+ unit = match.group(2).lower()
515
+ delta = timedelta(days=amount) if unit == "d" else timedelta(hours=amount)
516
+ start_ts = (dt_now - delta).timestamp()
517
+ else:
518
+ parsed = parse_timestamp(spec)
519
+ if parsed is None:
520
+ raise ValueError(
521
+ f"invalid 'since' spec: {spec!r} (expected ISO date, 'Nd', or 'Nh')"
522
+ )
523
+ start_ts = parsed
524
+ return cls(start=start_ts, end=now_ts, label=f"since {spec}")
525
+
526
+ @classmethod
527
+ def today(cls, now: float | None = None) -> "Window":
528
+ """Preset: current local calendar day ``[midnight, midnight+1d)``."""
529
+ base = window_today(now)
530
+ return cls(start=base.start, end=base.end, label=base.label)
531
+
532
+
533
+ def estimate_leg_cost(row: dict, pricing: dict) -> float | None:
534
+ """Estimate the token-based cost of one leg.
535
+
536
+ *pricing* is a plain ``dict`` keyed by canonical model name, e.g.::
537
+
538
+ {"sonnet": {"input": 3.00, "output": 15.00,
539
+ "cache_read": 0.30, "cache_creation": 3.75}, ...}
540
+
541
+ Rates are per 1 M tokens. Returns the estimated cost as a ``float``
542
+ (possibly ``0.0`` for a mapped model with zero tokens), or ``None`` when
543
+ the leg's model is not a key in *pricing* — never a silent ``$0`` for an
544
+ unmapped model. Captured ``cost_usd`` is **not** consulted here; the
545
+ caller decides whether to use captured or estimated cost.
546
+ """
547
+ key = canonical_model(row.get("model"))
548
+ rates = pricing.get(key)
549
+ if rates is None:
550
+ return None
551
+ return (
552
+ _to_int(row.get("input_tokens")) * rates["input"]
553
+ + _to_int(row.get("output_tokens")) * rates["output"]
554
+ + _to_int(row.get("cache_read_tokens")) * rates["cache_read"]
555
+ + _to_int(row.get("cache_creation_tokens")) * rates["cache_creation"]
556
+ ) / 1_000_000.0
557
+
558
+
559
+ # ── aggregate() helpers ───────────────────────────────────────────────────────
560
+
561
+
562
+ def _agg_key(row: dict, by: str) -> Any:
563
+ """Group key for :func:`aggregate`.
564
+
565
+ For ``by="issue"`` the key is the bare ``issue_number`` integer (unlike
566
+ the internal :func:`rollup` which uses an :class:`IssueKey` named-tuple so
567
+ it can distinguish the same number across repos). All other dimensions
568
+ delegate to :func:`_group_key_for`.
569
+
570
+ KNOWN LIMITATION (#1118 review, tracked, not fixed here): because the key
571
+ is a bare int, two different repos' issue ``#N`` collide into one
572
+ ``"by issue"`` group — GitHub issue numbers are per-repo, not globally
573
+ unique, and ``coordinator.yml`` is explicitly multi-repo. This matches
574
+ the sealed Gate-A acceptance contract's mock output shape
575
+ (``tests/acceptance/ms-37/contract.md``, a single ``issue`` column) and
576
+ the fixture's issue numbers (501/502) happen not to collide, so it isn't
577
+ caught by the sealed suite. Each group's ``"rows"`` list still carries
578
+ each row's real ``repo_name``, so a caller (CLI-1/#1115, CLI-2/#1119,
579
+ TUI/#1116) that cares about repo scoping for ``by="issue"`` can recover
580
+ it from there today; a real fix would need a repo-qualified key (e.g.
581
+ ``f"{repo_name}#{issue_number}"``) *and* a matching sealed-contract
582
+ update, which is out of scope for this PR.
583
+ """
584
+ if by == "issue":
585
+ # #1553: attributed issue, not the raw column — see row_issue_number.
586
+ return row_issue_number(row)
587
+ return _group_key_for(row, by)
588
+
589
+
590
+ def _empty_agg_group(key: Any) -> dict:
591
+ return {
592
+ "key": key,
593
+ "legs": 0,
594
+ "cost_captured": 0.0,
595
+ "cost_est": 0.0,
596
+ "cost_total": 0.0,
597
+ "tokens": {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0},
598
+ "duration_secs": 0.0,
599
+ "open_legs": 0,
600
+ "unknown_models": 0,
601
+ "rows": [],
602
+ }
603
+
604
+
605
+ def _accumulate_agg(group: dict, row: dict, pricing: dict) -> None:
606
+ """Accumulate one *row* into *group* and update all numeric fields."""
607
+ raw_cost = row.get("cost_usd")
608
+ try:
609
+ captured = float(raw_cost) if raw_cost not in (None, "") else 0.0
610
+ except (TypeError, ValueError):
611
+ captured = 0.0
612
+
613
+ if captured:
614
+ group["cost_captured"] += captured
615
+ else:
616
+ est = estimate_leg_cost(row, pricing)
617
+ if est is not None:
618
+ group["cost_est"] += est
619
+ else:
620
+ # Unmapped model + tokens → flag; neither captured nor estimated.
621
+ total_tok = (
622
+ _to_int(row.get("input_tokens"))
623
+ + _to_int(row.get("output_tokens"))
624
+ + _to_int(row.get("cache_read_tokens"))
625
+ + _to_int(row.get("cache_creation_tokens"))
626
+ )
627
+ if total_tok > 0:
628
+ group["unknown_models"] += 1
629
+
630
+ # Token sums include ALL in-window legs regardless of model mapping.
631
+ group["tokens"]["input"] += _to_int(row.get("input_tokens"))
632
+ group["tokens"]["output"] += _to_int(row.get("output_tokens"))
633
+ group["tokens"]["cache_read"] += _to_int(row.get("cache_read_tokens"))
634
+ group["tokens"]["cache_creation"] += _to_int(row.get("cache_creation_tokens"))
635
+
636
+ dur, is_open = leg_duration(row)
637
+ group["duration_secs"] += dur
638
+ if is_open:
639
+ group["open_legs"] += 1
640
+
641
+ group["legs"] += 1
642
+ group["rows"].append(row)
643
+
644
+
645
+ def aggregate(
646
+ rows: Iterable[dict],
647
+ *,
648
+ by: str,
649
+ window: TimeWindow,
650
+ pricing: dict,
651
+ ) -> dict:
652
+ """Aggregate *rows* into a plain rollup dict — the Gate-A public surface.
653
+
654
+ Parameters
655
+ ----------
656
+ rows:
657
+ Iterable of assignment row dicts (daemon ``/board`` wire format).
658
+ by:
659
+ Grouping dimension: ``"issue"``, ``"repo"``, ``"day"``, ``"week"``,
660
+ ``"month"``, or ``"stage"``.
661
+ window:
662
+ Half-open ``[start, end)`` interval. Any :class:`TimeWindow` (or
663
+ :class:`Window`) is accepted. Only rows whose ``dispatched_at``
664
+ **or** ``finished_at`` falls inside are counted.
665
+ pricing:
666
+ Plain ``dict`` keyed by canonical model name with per-1M-token rates,
667
+ e.g. ``{"sonnet": {"input": 3.00, "output": 15.00,
668
+ "cache_read": 0.30, "cache_creation": 3.75}}``.
669
+
670
+ Returns
671
+ -------
672
+ dict
673
+ ``"by"``: the *by* dimension string.
674
+ ``"groups"``: list of group dicts sorted **descending** by
675
+ ``cost_total`` (``cost_captured + cost_est``). Each group dict has
676
+ keys ``key``, ``legs``, ``cost_captured``, ``cost_est``,
677
+ ``cost_total``, ``tokens`` (dict), ``duration_secs``, ``open_legs``,
678
+ ``unknown_models``, ``rows``.
679
+ ``"totals"``: a single dict with the same numeric keys summed across
680
+ all in-window legs.
681
+ """
682
+ if by not in _VALID_GROUP_BY:
683
+ raise ValueError(f"unknown 'by': {by!r} (expected one of {_VALID_GROUP_BY})")
684
+
685
+ groups: dict[Any, dict] = {}
686
+ totals = _empty_agg_group(None)
687
+
688
+ for row in rows:
689
+ if not in_window(row, window):
690
+ continue
691
+ key = _agg_key(row, by)
692
+ if key is None:
693
+ continue
694
+ if key not in groups:
695
+ groups[key] = _empty_agg_group(key)
696
+ _accumulate_agg(groups[key], row, pricing)
697
+ _accumulate_agg(totals, row, pricing)
698
+
699
+ # Sort groups descending by total cost and materialise the derived field.
700
+ sorted_groups = sorted(
701
+ groups.values(),
702
+ key=lambda g: g["cost_captured"] + g["cost_est"],
703
+ reverse=True,
704
+ )
705
+ for g in sorted_groups:
706
+ g["cost_total"] = g["cost_captured"] + g["cost_est"]
707
+ totals["cost_total"] = totals["cost_captured"] + totals["cost_est"]
708
+
709
+ return {"by": by, "groups": sorted_groups, "totals": totals}