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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- 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}
|