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.py
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
"""Usage tracking: parse token/cost data from worker stream-json logs.
|
|
2
|
+
|
|
3
|
+
Provides per-assignment and per-model cost breakdowns, session burn rate,
|
|
4
|
+
and summary helpers for ``coord usage`` and the burn-rate warning in
|
|
5
|
+
``coord status``.
|
|
6
|
+
|
|
7
|
+
Usage data is collected from two sources:
|
|
8
|
+
|
|
9
|
+
* **Local logs** — ``~/.coord/logs/<assignment_id>.log`` (stream-json).
|
|
10
|
+
These exist when the agent ran on the same machine as the coordinator.
|
|
11
|
+
* **Remote agent status** — HTTP ``/status`` on agent servers.
|
|
12
|
+
The agent already reports ``cost_so_far`` / ``total_cost_usd`` in its
|
|
13
|
+
``list_assignments()`` response; we use those when a local log is absent.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from coord.models import Assignment
|
|
25
|
+
|
|
26
|
+
# Re-export COORD_DIR so callers don't need to import state directly.
|
|
27
|
+
from coord.state import COORD_DIR
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from coord.providers.base import Provider
|
|
31
|
+
|
|
32
|
+
_log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
LOGS_DIR = COORD_DIR / "logs"
|
|
35
|
+
|
|
36
|
+
# Burn rate threshold ($/hr) above which coord status shows a warning line.
|
|
37
|
+
HIGH_BURN_RATE_USD_PER_HOUR = 2.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── Data classes ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class AssignmentUsage:
|
|
45
|
+
"""Cost/usage data for a single assignment."""
|
|
46
|
+
|
|
47
|
+
assignment_id: str
|
|
48
|
+
repo_name: str
|
|
49
|
+
issue_number: int
|
|
50
|
+
issue_title: str
|
|
51
|
+
status: str # pending | running | done | failed
|
|
52
|
+
model: str | None = None
|
|
53
|
+
total_cost_usd: float = 0.0
|
|
54
|
+
num_turns: int = 0
|
|
55
|
+
duration_ms: int | None = None
|
|
56
|
+
input_tokens: int = 0
|
|
57
|
+
output_tokens: int = 0
|
|
58
|
+
cache_creation_tokens: int = 0
|
|
59
|
+
cache_read_tokens: int = 0
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def total_tokens(self) -> int:
|
|
63
|
+
return self.input_tokens + self.output_tokens
|
|
64
|
+
|
|
65
|
+
def duration_str(self) -> str:
|
|
66
|
+
if self.duration_ms is None:
|
|
67
|
+
return "?"
|
|
68
|
+
s = self.duration_ms / 1000.0
|
|
69
|
+
if s < 60:
|
|
70
|
+
return f"{s:.0f}s"
|
|
71
|
+
m, sec = divmod(int(s), 60)
|
|
72
|
+
if m < 60:
|
|
73
|
+
return f"{m}m {sec}s"
|
|
74
|
+
h, m = divmod(m, 60)
|
|
75
|
+
return f"{h}h {m}m"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class SessionUsage:
|
|
80
|
+
"""Aggregated usage across all assignments in the current session."""
|
|
81
|
+
|
|
82
|
+
started_at: float | None = None # Unix timestamp from session.json
|
|
83
|
+
assignments: list[AssignmentUsage] = field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def total_cost_usd(self) -> float:
|
|
87
|
+
return sum(a.total_cost_usd for a in self.assignments)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def total_input_tokens(self) -> int:
|
|
91
|
+
return sum(a.input_tokens for a in self.assignments)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def total_output_tokens(self) -> int:
|
|
95
|
+
return sum(a.output_tokens for a in self.assignments)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def elapsed_hours(self) -> float | None:
|
|
99
|
+
"""Hours since session start; None if no session timestamp."""
|
|
100
|
+
if self.started_at is None:
|
|
101
|
+
return None
|
|
102
|
+
elapsed_sec = time.time() - self.started_at
|
|
103
|
+
# Guard against negative/zero to avoid division weirdness.
|
|
104
|
+
return max(elapsed_sec, 60.0) / 3600.0
|
|
105
|
+
|
|
106
|
+
def burn_rate_usd_per_hour(self) -> float | None:
|
|
107
|
+
"""$/hr based on total cost and elapsed session time.
|
|
108
|
+
|
|
109
|
+
Returns None if the session start time is unknown.
|
|
110
|
+
"""
|
|
111
|
+
hours = self.elapsed_hours
|
|
112
|
+
if hours is None:
|
|
113
|
+
return None
|
|
114
|
+
if self.total_cost_usd == 0.0:
|
|
115
|
+
return 0.0
|
|
116
|
+
return self.total_cost_usd / hours
|
|
117
|
+
|
|
118
|
+
def cost_by_model(self) -> dict[str, float]:
|
|
119
|
+
"""Map model name → total cost across all assignments."""
|
|
120
|
+
result: dict[str, float] = {}
|
|
121
|
+
for a in self.assignments:
|
|
122
|
+
key = a.model or "(unknown)"
|
|
123
|
+
result[key] = result.get(key, 0.0) + a.total_cost_usd
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
def count_by_model(self) -> dict[str, int]:
|
|
127
|
+
"""Map model name → number of assignments."""
|
|
128
|
+
result: dict[str, int] = {}
|
|
129
|
+
for a in self.assignments:
|
|
130
|
+
key = a.model or "(unknown)"
|
|
131
|
+
result[key] = result.get(key, 0) + 1
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── Log parsing ───────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_usage_from_log(
|
|
139
|
+
log_path: Path,
|
|
140
|
+
*,
|
|
141
|
+
provider_name: str | None = None,
|
|
142
|
+
provider: "Provider | None" = None,
|
|
143
|
+
) -> AssignmentUsage | None:
|
|
144
|
+
"""Parse an :class:`AssignmentUsage` from a worker log file.
|
|
145
|
+
|
|
146
|
+
Returns ``None`` if the file doesn't exist, isn't stream-json, or can't
|
|
147
|
+
be parsed. The returned object has placeholder values for fields that
|
|
148
|
+
aren't available from the log alone (``assignment_id``, ``repo_name``,
|
|
149
|
+
etc.) — callers must fill those in from the board.
|
|
150
|
+
|
|
151
|
+
#1710: cost/token parsing is routed through the assignment's resolved
|
|
152
|
+
:class:`~coord.providers.base.Provider` (``provider.parse_log()``)
|
|
153
|
+
instead of assuming :mod:`coord.worker_events`'s claude-shaped parser.
|
|
154
|
+
*provider_name* (typically ``Assignment.provider_name``) resolves via
|
|
155
|
+
:func:`coord.providers.get_provider`; ``None`` defaults to
|
|
156
|
+
:class:`~coord.providers.claude.ClaudeProvider`, matching pre-#1710
|
|
157
|
+
behaviour byte-for-byte for every caller that doesn't pass it. *provider*
|
|
158
|
+
is an escape hatch to pass an already-constructed provider directly
|
|
159
|
+
(tests; bypasses name resolution).
|
|
160
|
+
"""
|
|
161
|
+
from coord.worker_events import is_stream_json
|
|
162
|
+
|
|
163
|
+
if not log_path.exists():
|
|
164
|
+
return None
|
|
165
|
+
if provider is None:
|
|
166
|
+
from coord.providers import get_provider # noqa: PLC0415
|
|
167
|
+
provider = get_provider(provider_name)
|
|
168
|
+
if not is_stream_json(log_path):
|
|
169
|
+
# #1710: a provider that claims it reports cost but whose log isn't
|
|
170
|
+
# stream-json shaped gets NO cost/token data here — that mismatch is
|
|
171
|
+
# worth a loud signal rather than a silent, indistinguishable-from-
|
|
172
|
+
# "no cost yet" None. A provider that legitimately never reports cost
|
|
173
|
+
# (e.g. claude-pty, subscription-billed) sets
|
|
174
|
+
# capabilities().cost_reporting=False and stays silent, as before.
|
|
175
|
+
if provider.capabilities().cost_reporting:
|
|
176
|
+
_log.warning(
|
|
177
|
+
"parse_usage_from_log: %s is not stream-json but provider "
|
|
178
|
+
"%r reports capabilities().cost_reporting=True — no cost/"
|
|
179
|
+
"token data will be captured for this assignment (#1710)",
|
|
180
|
+
log_path, provider_name or "claude",
|
|
181
|
+
)
|
|
182
|
+
return None
|
|
183
|
+
try:
|
|
184
|
+
summary = provider.parse_log(log_path, tail_bytes=0)
|
|
185
|
+
except OSError:
|
|
186
|
+
return None
|
|
187
|
+
return AssignmentUsage(
|
|
188
|
+
assignment_id="", # caller fills in
|
|
189
|
+
repo_name="", # caller fills in
|
|
190
|
+
issue_number=0, # caller fills in
|
|
191
|
+
issue_title="", # caller fills in
|
|
192
|
+
status="", # caller fills in
|
|
193
|
+
model=summary.model_used,
|
|
194
|
+
total_cost_usd=summary.total_cost_usd,
|
|
195
|
+
num_turns=summary.num_turns,
|
|
196
|
+
duration_ms=summary.duration_ms,
|
|
197
|
+
input_tokens=summary.input_tokens,
|
|
198
|
+
output_tokens=summary.output_tokens,
|
|
199
|
+
cache_creation_tokens=summary.cache_creation_tokens,
|
|
200
|
+
cache_read_tokens=summary.cache_read_tokens,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _assignment_to_usage(
|
|
205
|
+
a: Assignment,
|
|
206
|
+
*,
|
|
207
|
+
logs_dir: Path | None = None,
|
|
208
|
+
remote_data: dict | None = None,
|
|
209
|
+
) -> AssignmentUsage:
|
|
210
|
+
"""Build an :class:`AssignmentUsage` for *a*.
|
|
211
|
+
|
|
212
|
+
Priority: local log file > *remote_data* dict (from agent HTTP) >
|
|
213
|
+
Assignment.model field as a final model fallback.
|
|
214
|
+
|
|
215
|
+
*remote_data* is a dict from the agent's ``list_assignments()`` response
|
|
216
|
+
(e.g. ``{"cost_so_far": 0.12, "model_used": "claude-sonnet-4-6", ...}``).
|
|
217
|
+
"""
|
|
218
|
+
_logs_dir = logs_dir if logs_dir is not None else LOGS_DIR
|
|
219
|
+
from coord.models import effective_issue_number # noqa: PLC0415
|
|
220
|
+
|
|
221
|
+
usage = AssignmentUsage(
|
|
222
|
+
assignment_id=a.assignment_id or "",
|
|
223
|
+
repo_name=a.repo_name,
|
|
224
|
+
# #1553: per-issue spend must land on the issue the work was really
|
|
225
|
+
# for. An oracle-loop acceptance slice carries the milestone's
|
|
226
|
+
# tracking issue in `issue_number`, so booking cost against it
|
|
227
|
+
# attributed every child's authoring spend to the epic.
|
|
228
|
+
issue_number=effective_issue_number(a),
|
|
229
|
+
issue_title=a.issue_title,
|
|
230
|
+
status=a.status,
|
|
231
|
+
model=a.model,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Try local log first.
|
|
235
|
+
if a.assignment_id:
|
|
236
|
+
log_path = _logs_dir / f"{a.assignment_id}.log"
|
|
237
|
+
# #1710: thread the assignment's resolved provider name through so
|
|
238
|
+
# the parse uses the right provider's parse_log() rather than always
|
|
239
|
+
# assuming claude.
|
|
240
|
+
parsed = parse_usage_from_log(log_path, provider_name=a.provider_name)
|
|
241
|
+
if parsed is not None:
|
|
242
|
+
usage.model = parsed.model or a.model
|
|
243
|
+
usage.total_cost_usd = parsed.total_cost_usd
|
|
244
|
+
usage.num_turns = parsed.num_turns
|
|
245
|
+
usage.duration_ms = parsed.duration_ms
|
|
246
|
+
usage.input_tokens = parsed.input_tokens
|
|
247
|
+
usage.output_tokens = parsed.output_tokens
|
|
248
|
+
usage.cache_creation_tokens = parsed.cache_creation_tokens
|
|
249
|
+
usage.cache_read_tokens = parsed.cache_read_tokens
|
|
250
|
+
return usage
|
|
251
|
+
|
|
252
|
+
# Fall back to remote agent data if available.
|
|
253
|
+
if remote_data:
|
|
254
|
+
cost = remote_data.get("total_cost_usd") or remote_data.get("cost_so_far") or 0.0
|
|
255
|
+
usage.total_cost_usd = float(cost)
|
|
256
|
+
model_r = remote_data.get("model_used") or remote_data.get("model")
|
|
257
|
+
if model_r:
|
|
258
|
+
usage.model = str(model_r)
|
|
259
|
+
turns = remote_data.get("num_turns") or remote_data.get("turns")
|
|
260
|
+
if isinstance(turns, int):
|
|
261
|
+
usage.num_turns = turns
|
|
262
|
+
|
|
263
|
+
return usage
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ── Session collection ────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def collect_usage(
|
|
270
|
+
board_assignments: list[Assignment],
|
|
271
|
+
*,
|
|
272
|
+
logs_dir: Path | None = None,
|
|
273
|
+
remote_by_id: dict[str, dict] | None = None,
|
|
274
|
+
) -> list[AssignmentUsage]:
|
|
275
|
+
"""Collect :class:`AssignmentUsage` for every assignment on the board.
|
|
276
|
+
|
|
277
|
+
*remote_by_id* maps ``assignment_id → agent_status_dict`` for assignments
|
|
278
|
+
whose logs live on a remote machine. Pass ``None`` (the default) to skip
|
|
279
|
+
remote lookups entirely — the result will still be correct for any
|
|
280
|
+
assignment whose log is available locally.
|
|
281
|
+
"""
|
|
282
|
+
result: list[AssignmentUsage] = []
|
|
283
|
+
for a in board_assignments:
|
|
284
|
+
if not a.assignment_id:
|
|
285
|
+
continue
|
|
286
|
+
remote = (remote_by_id or {}).get(a.assignment_id)
|
|
287
|
+
result.append(_assignment_to_usage(a, logs_dir=logs_dir, remote_data=remote))
|
|
288
|
+
return result
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def build_session_usage(
|
|
292
|
+
board_assignments: list[Assignment],
|
|
293
|
+
*,
|
|
294
|
+
logs_dir: Path | None = None,
|
|
295
|
+
remote_by_id: dict[str, dict] | None = None,
|
|
296
|
+
started_at: float | None = None,
|
|
297
|
+
) -> SessionUsage:
|
|
298
|
+
"""Build a :class:`SessionUsage` from the current board.
|
|
299
|
+
|
|
300
|
+
*started_at* should come from ``session.json["started_at"]`` (parsed to
|
|
301
|
+
a Unix timestamp). If not provided we fall back to the oldest
|
|
302
|
+
``dispatched_at`` among the assignments.
|
|
303
|
+
"""
|
|
304
|
+
if started_at is None:
|
|
305
|
+
# Derive from oldest dispatch time on the board.
|
|
306
|
+
times = [
|
|
307
|
+
a.dispatched_at
|
|
308
|
+
for a in board_assignments
|
|
309
|
+
if a.dispatched_at is not None
|
|
310
|
+
]
|
|
311
|
+
started_at = min(times) if times else None
|
|
312
|
+
|
|
313
|
+
assignments = collect_usage(
|
|
314
|
+
board_assignments,
|
|
315
|
+
logs_dir=logs_dir,
|
|
316
|
+
remote_by_id=remote_by_id,
|
|
317
|
+
)
|
|
318
|
+
return SessionUsage(started_at=started_at, assignments=assignments)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def filter_assignments_in_window(assignments: list[Assignment], window) -> list[Assignment]:
|
|
322
|
+
"""Filter *assignments* to those in-window (#1119 review finding #1).
|
|
323
|
+
|
|
324
|
+
An assignment is in-window if its ``dispatched_at`` **or** ``finished_at``
|
|
325
|
+
falls inside *window* (a :class:`~coord.usage_rollup.TimeWindow`/
|
|
326
|
+
``Window``) — the same semantics :func:`coord.usage_rollup.leg_in_window`
|
|
327
|
+
applies to daemon-row dicts, applied here directly to ``Assignment``
|
|
328
|
+
timestamps (already Unix floats, no ISO parsing needed). This lets the
|
|
329
|
+
legacy ``coord usage`` view (no ``--by``/``--by-time``/``--by-issue``/
|
|
330
|
+
``--issue``) actually honor ``--today``/``--week``/``--month``/``--since``
|
|
331
|
+
instead of silently ignoring them.
|
|
332
|
+
"""
|
|
333
|
+
return [
|
|
334
|
+
a for a in assignments
|
|
335
|
+
if window.contains(a.dispatched_at) or window.contains(a.finished_at)
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ── Rollup fetch (#1118) ──────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def fetch_usage_rows(
|
|
343
|
+
*,
|
|
344
|
+
flag_url: str | None = None,
|
|
345
|
+
flag_token: str | None = None,
|
|
346
|
+
timeout: float = 5.0,
|
|
347
|
+
) -> list[dict]:
|
|
348
|
+
"""Fetch board assignment rows as plain dicts for :mod:`coord.usage_rollup`.
|
|
349
|
+
|
|
350
|
+
This is the thin *fetch* caller the pure aggregator's docstring promises:
|
|
351
|
+
it mirrors :func:`coord.board_service.read_board`'s local-vs-remote
|
|
352
|
+
branch, but returns raw wire-shape ``dict`` rows rather than
|
|
353
|
+
:class:`~coord.models.Assignment` instances — the aggregator consumes the
|
|
354
|
+
daemon ``/board`` ``assignments`` shape directly, including
|
|
355
|
+
``is_interactive``, which is a real DB/wire column but (deliberately,
|
|
356
|
+
#748/#632 — see :mod:`coord.board_bool_guard`) not an ``Assignment``
|
|
357
|
+
dataclass field, so converting through ``Assignment`` would silently
|
|
358
|
+
drop it.
|
|
359
|
+
|
|
360
|
+
Remote reads the same ``/board`` endpoint ``coord status`` polls. Local
|
|
361
|
+
reads the sqlite DB directly via ``SqliteStore.list_assignments()``
|
|
362
|
+
rather than ``board_projection()["assignments"]`` — a usage rollup wants
|
|
363
|
+
full history, not the retention-capped set ``/board`` serves the TUI.
|
|
364
|
+
"""
|
|
365
|
+
from coord.client import fetch_board_payload, resolve_board_service # noqa: PLC0415
|
|
366
|
+
|
|
367
|
+
svc = resolve_board_service(flag_url, flag_token)
|
|
368
|
+
if svc is not None:
|
|
369
|
+
payload = fetch_board_payload(svc, timeout=timeout)
|
|
370
|
+
return list(payload.get("assignments") or [])
|
|
371
|
+
|
|
372
|
+
from coord.dao import SqliteStore # noqa: PLC0415
|
|
373
|
+
|
|
374
|
+
return SqliteStore().list_assignments()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ── Formatting ────────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _fmt_cost(usd: float) -> str:
|
|
381
|
+
if usd < 0.001:
|
|
382
|
+
return "$0.00"
|
|
383
|
+
if usd < 0.01:
|
|
384
|
+
return f"${usd:.4f}"
|
|
385
|
+
return f"${usd:.2f}"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _fmt_burn_rate(usd_per_hr: float) -> str:
|
|
389
|
+
if usd_per_hr < 0.01:
|
|
390
|
+
return f"${usd_per_hr:.4f}/hr"
|
|
391
|
+
return f"${usd_per_hr:.2f}/hr"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def format_usage_report(session: SessionUsage, window_label: str | None = None) -> str:
|
|
395
|
+
"""Return the full multi-section usage report for ``coord usage``.
|
|
396
|
+
|
|
397
|
+
*window_label* is set when the caller resolved one of ``--today``/
|
|
398
|
+
``--week``/``--month``/``--since`` and pre-filtered *session*'s
|
|
399
|
+
assignments to that window (#1119 review finding #1) — when given, a
|
|
400
|
+
``USAGE — window: ...`` header and a ``Σ total`` grand-total footer are
|
|
401
|
+
printed, mirroring the convention :func:`format_usage_by_group`/
|
|
402
|
+
:func:`format_usage_by_time` already use, so the resolved window is
|
|
403
|
+
visible even on the legacy (no ``--by``) view. Left as ``None`` (the
|
|
404
|
+
default — no window flag was given), the report is byte-for-byte
|
|
405
|
+
unchanged from before #1119.
|
|
406
|
+
"""
|
|
407
|
+
lines: list[str] = []
|
|
408
|
+
|
|
409
|
+
if window_label is not None:
|
|
410
|
+
lines.append(f"USAGE — window: {window_label}")
|
|
411
|
+
lines.append("")
|
|
412
|
+
|
|
413
|
+
# ── Session header ────────────────────────────────────────────────────
|
|
414
|
+
burn = session.burn_rate_usd_per_hour()
|
|
415
|
+
burn_str = _fmt_burn_rate(burn) if burn is not None else "(no session time)"
|
|
416
|
+
high_flag = " ⚠" if burn is not None and burn >= HIGH_BURN_RATE_USD_PER_HOUR else ""
|
|
417
|
+
|
|
418
|
+
n_done = sum(1 for a in session.assignments if a.status == "done")
|
|
419
|
+
n_running = sum(1 for a in session.assignments if a.status == "running")
|
|
420
|
+
n_failed = sum(1 for a in session.assignments if a.status == "failed")
|
|
421
|
+
counts: list[str] = []
|
|
422
|
+
if n_done:
|
|
423
|
+
counts.append(f"{n_done} done")
|
|
424
|
+
if n_running:
|
|
425
|
+
counts.append(f"{n_running} running")
|
|
426
|
+
if n_failed:
|
|
427
|
+
counts.append(f"{n_failed} failed")
|
|
428
|
+
counts_str = ", ".join(counts) if counts else "0 assignments"
|
|
429
|
+
|
|
430
|
+
total_str = _fmt_cost(session.total_cost_usd)
|
|
431
|
+
lines.append(f"Session usage: {total_str} • {counts_str} • burn rate: {burn_str}{high_flag}")
|
|
432
|
+
|
|
433
|
+
# Budget remaining estimate: max(0, 5hr - elapsed) * burn_rate
|
|
434
|
+
elapsed_hours = session.elapsed_hours
|
|
435
|
+
if elapsed_hours is not None and burn is not None and burn > 0:
|
|
436
|
+
budget_remaining_usd = max(0.0, 5.0 - elapsed_hours) * burn
|
|
437
|
+
lines.append(
|
|
438
|
+
f"Est. 5hr budget remaining: ~{_fmt_cost(budget_remaining_usd)} (based on current rate)"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
lines.append("")
|
|
442
|
+
|
|
443
|
+
# ── Per-assignment table ──────────────────────────────────────────────
|
|
444
|
+
if not session.assignments:
|
|
445
|
+
lines.append("No assignments found.")
|
|
446
|
+
if window_label is not None:
|
|
447
|
+
lines.append("")
|
|
448
|
+
lines.append(f"Σ total {total_str} • {counts_str}")
|
|
449
|
+
return "\n".join(lines)
|
|
450
|
+
|
|
451
|
+
lines.append("Per-assignment:")
|
|
452
|
+
col_id_w = max(8, max(len(a.assignment_id[:8]) for a in session.assignments))
|
|
453
|
+
col_repo_w = max(8, max(len(a.repo_name) for a in session.assignments))
|
|
454
|
+
col_model_w = max(5, max(len(a.model or "(unknown)") for a in session.assignments))
|
|
455
|
+
|
|
456
|
+
header = (
|
|
457
|
+
f" {'ID':<{col_id_w}} {'STATUS':<7} {'REPO':<{col_repo_w}} "
|
|
458
|
+
f"{'#':>5} {'MODEL':<{col_model_w}} {'TURNS':>5} {'DUR':>7} COST"
|
|
459
|
+
)
|
|
460
|
+
lines.append(header)
|
|
461
|
+
lines.append(" " + "-" * (len(header) - 2))
|
|
462
|
+
|
|
463
|
+
for a in session.assignments:
|
|
464
|
+
aid = (a.assignment_id or "")[:8]
|
|
465
|
+
model = a.model or "(unknown)"
|
|
466
|
+
dur = a.duration_str()
|
|
467
|
+
cost = _fmt_cost(a.total_cost_usd)
|
|
468
|
+
line = (
|
|
469
|
+
f" {aid:<{col_id_w}} {a.status:<7} {a.repo_name:<{col_repo_w}} "
|
|
470
|
+
f"#{a.issue_number:>4} {model:<{col_model_w}} {a.num_turns:>5} "
|
|
471
|
+
f"{dur:>7} {cost}"
|
|
472
|
+
)
|
|
473
|
+
lines.append(line)
|
|
474
|
+
|
|
475
|
+
# ── Token summary (only shown when any tokens were recorded) ──────────
|
|
476
|
+
total_in = session.total_input_tokens
|
|
477
|
+
total_out = session.total_output_tokens
|
|
478
|
+
if total_in or total_out:
|
|
479
|
+
lines.append("")
|
|
480
|
+
lines.append(f"Token totals: {total_in:,} input • {total_out:,} output")
|
|
481
|
+
|
|
482
|
+
# ── Per-model breakdown ───────────────────────────────────────────────
|
|
483
|
+
lines.append("")
|
|
484
|
+
lines.append("Per-model:")
|
|
485
|
+
cost_by = session.cost_by_model()
|
|
486
|
+
count_by = session.count_by_model()
|
|
487
|
+
total = session.total_cost_usd
|
|
488
|
+
|
|
489
|
+
for model_name in sorted(cost_by, key=lambda m: cost_by[m], reverse=True):
|
|
490
|
+
n = count_by[model_name]
|
|
491
|
+
c = cost_by[model_name]
|
|
492
|
+
pct = f" ({100 * c / total:.0f}%)" if total > 0 else ""
|
|
493
|
+
noun = "assignment" if n == 1 else "assignments"
|
|
494
|
+
lines.append(f" {model_name:<{col_model_w}} {n} {noun:<12} {_fmt_cost(c)}{pct}")
|
|
495
|
+
|
|
496
|
+
if window_label is not None:
|
|
497
|
+
lines.append("")
|
|
498
|
+
lines.append(f"Σ total {total_str} • {counts_str}")
|
|
499
|
+
|
|
500
|
+
return "\n".join(lines)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ── Per-issue rollup rendering (#1115 CLI-1) ─────────────────────────────────
|
|
504
|
+
#
|
|
505
|
+
# Consumes coord.usage_rollup.aggregate() (#1118 Core) — this module only
|
|
506
|
+
# renders the plain dict it returns. Distinct 4-decimal cost formatting and
|
|
507
|
+
# compact token/duration formatting are used here (vs. the 2-decimal
|
|
508
|
+
# _fmt_cost/duration_str above) to match the sealed contract mocks exactly
|
|
509
|
+
# (tests/acceptance/ms-37/contract.md, Mocks 1 & 2).
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def pricing_dict_from_config(pricing) -> dict:
|
|
513
|
+
"""Convert a :class:`~coord.config.PricingConfig` to the plain
|
|
514
|
+
``{model: {"input": ..., "output": ..., "cache_read": ..., "cache_creation": ...}}``
|
|
515
|
+
dict :func:`coord.usage_rollup.aggregate` expects."""
|
|
516
|
+
return {
|
|
517
|
+
model: {
|
|
518
|
+
"input": rates.input,
|
|
519
|
+
"output": rates.output,
|
|
520
|
+
"cache_read": rates.cache_read,
|
|
521
|
+
"cache_creation": rates.cache_creation,
|
|
522
|
+
}
|
|
523
|
+
for model, rates in pricing.models.items()
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _fmt_cost4(usd: float) -> str:
|
|
528
|
+
"""Captured-cost formatting for the rollup views: always 4 decimals."""
|
|
529
|
+
return f"${usd:.4f}"
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _fmt_est4(usd: float) -> str:
|
|
533
|
+
"""Estimated-cost formatting: ``~$`` prefix, always visually distinct
|
|
534
|
+
from a captured figure (see :func:`_fmt_cost4`)."""
|
|
535
|
+
return f"~${usd:.4f}"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _fmt_tokens_compact(n: int) -> str:
|
|
539
|
+
"""Compact token count: ``k`` below 1M, one-decimal ``M`` at/above."""
|
|
540
|
+
if n >= 1_000_000:
|
|
541
|
+
return f"{n / 1_000_000:.1f}M"
|
|
542
|
+
if n >= 1_000:
|
|
543
|
+
return f"{round(n / 1_000)}k"
|
|
544
|
+
return str(n)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _fmt_duration_hms(secs: float | None, *, is_open: bool) -> str:
|
|
548
|
+
"""``NmSSs`` duration (e.g. ``20m00s``); ``—`` for an open/unknown leg."""
|
|
549
|
+
if is_open or secs is None:
|
|
550
|
+
return "—"
|
|
551
|
+
total = int(round(secs))
|
|
552
|
+
m, s = divmod(total, 60)
|
|
553
|
+
return f"{m}m{s:02d}s"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def format_usage_by_issue(result: dict, window_label: str) -> str:
|
|
557
|
+
"""Render the ``coord usage --by-issue`` view (contract Mock 1) from an
|
|
558
|
+
:func:`coord.usage_rollup.aggregate` result (``by="issue"``).
|
|
559
|
+
|
|
560
|
+
*window_label* is the resolved :class:`~coord.usage_rollup.Window`'s
|
|
561
|
+
``label`` (e.g. ``"today"``) — printed in the header, not consumed by
|
|
562
|
+
the aggregator itself.
|
|
563
|
+
"""
|
|
564
|
+
lines = [f"USAGE — by issue — window: {window_label}"]
|
|
565
|
+
lines.append(
|
|
566
|
+
f"{'issue':<8}{'repo':<10}{'legs':>4} {'cost':<10} {'est(~)':<11} "
|
|
567
|
+
f"{'out / cache':<20}{'time':<10}note"
|
|
568
|
+
)
|
|
569
|
+
for g in result["groups"]:
|
|
570
|
+
issue_no = g["key"]
|
|
571
|
+
repo = g["rows"][0].get("repo_name", "") if g["rows"] else ""
|
|
572
|
+
cost_str = _fmt_cost4(g["cost_captured"]) if g["cost_captured"] > 0 else "—"
|
|
573
|
+
est_str = _fmt_est4(g["cost_est"]) if g["cost_est"] > 0 else "—"
|
|
574
|
+
out_str = _fmt_tokens_compact(g["tokens"]["output"])
|
|
575
|
+
cache_str = _fmt_tokens_compact(g["tokens"]["cache_read"])
|
|
576
|
+
tok_str = f"{out_str} / {cache_str}"
|
|
577
|
+
dur_str = _fmt_duration_hms(g["duration_secs"], is_open=False)
|
|
578
|
+
note = f"⚠ unknown-model:{g['unknown_models']}" if g["unknown_models"] else ""
|
|
579
|
+
lines.append(
|
|
580
|
+
f"#{issue_no:<7}{repo:<10}{g['legs']:>4} {cost_str:<10} {est_str:<11} "
|
|
581
|
+
f"{tok_str:<20}{dur_str:<10}{note}"
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
lines.append("─" * 80)
|
|
585
|
+
t = result["totals"]
|
|
586
|
+
total_out = _fmt_tokens_compact(t["tokens"]["output"])
|
|
587
|
+
total_cache = _fmt_tokens_compact(t["tokens"]["cache_read"])
|
|
588
|
+
total_dur = _fmt_duration_hms(t["duration_secs"], is_open=False)
|
|
589
|
+
progress = f" · {t['open_legs']} in progress" if t["open_legs"] else ""
|
|
590
|
+
lines.append(
|
|
591
|
+
f"Σ captured {_fmt_cost4(t['cost_captured'])} · est {_fmt_est4(t['cost_est'])} · "
|
|
592
|
+
f"total {_fmt_cost4(t['cost_total'])} · {total_out} out / {total_cache} cache · "
|
|
593
|
+
f"{total_dur}{progress}"
|
|
594
|
+
)
|
|
595
|
+
return "\n".join(lines)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def format_usage_issue_drill(rows: list[dict], issue_number: int, pricing) -> str:
|
|
599
|
+
"""Render the ``coord usage --issue N`` per-stage drill (contract Mock 2).
|
|
600
|
+
|
|
601
|
+
*rows* are the raw board-row dicts for this one issue (any window
|
|
602
|
+
filtering already applied by the caller); *pricing* is a
|
|
603
|
+
:class:`~coord.config.PricingConfig`. Rows are rendered oldest-first by
|
|
604
|
+
``dispatched_at`` (falling back to ``finished_at``).
|
|
605
|
+
"""
|
|
606
|
+
from coord.usage_rollup import leg_cost, leg_duration, parse_timestamp
|
|
607
|
+
|
|
608
|
+
if not rows:
|
|
609
|
+
return f"No usage data for issue #{issue_number}."
|
|
610
|
+
|
|
611
|
+
def _sort_ts(row: dict) -> float:
|
|
612
|
+
ts = parse_timestamp(row.get("dispatched_at"))
|
|
613
|
+
if ts is None:
|
|
614
|
+
ts = parse_timestamp(row.get("finished_at"))
|
|
615
|
+
return ts if ts is not None else float("inf")
|
|
616
|
+
|
|
617
|
+
ordered = sorted(rows, key=_sort_ts)
|
|
618
|
+
repo = rows[0].get("repo_name", "")
|
|
619
|
+
|
|
620
|
+
total_captured = 0.0
|
|
621
|
+
total_est = 0.0
|
|
622
|
+
for row in rows:
|
|
623
|
+
captured, est, _unknown = leg_cost(row, pricing)
|
|
624
|
+
total_captured += captured
|
|
625
|
+
total_est += est
|
|
626
|
+
|
|
627
|
+
lines = [
|
|
628
|
+
f"#{issue_number} {repo} {_fmt_cost4(total_captured)} captured + "
|
|
629
|
+
f"{_fmt_est4(total_est)} est"
|
|
630
|
+
]
|
|
631
|
+
lines.append(
|
|
632
|
+
f"{'stage':<9}{'model':<11}{'int':<5}{'cost':<11}{'est(~)':<11}"
|
|
633
|
+
f"{'out':<7}{'cache':<8}{'time':<10}status"
|
|
634
|
+
)
|
|
635
|
+
for row in ordered:
|
|
636
|
+
captured, est, unknown_model = leg_cost(row, pricing)
|
|
637
|
+
duration, is_open = leg_duration(row)
|
|
638
|
+
stage = str(row.get("type") or "")
|
|
639
|
+
model = str(row.get("model") or "(unknown)")
|
|
640
|
+
interactive = "I" if row.get("is_interactive") else "-"
|
|
641
|
+
cost_col = _fmt_cost4(captured) if captured > 0 else "—"
|
|
642
|
+
if est > 0:
|
|
643
|
+
est_col = _fmt_est4(est)
|
|
644
|
+
elif unknown_model:
|
|
645
|
+
est_col = "n/a*"
|
|
646
|
+
else:
|
|
647
|
+
est_col = "—"
|
|
648
|
+
out_col = _fmt_tokens_compact(int(row.get("output_tokens") or 0))
|
|
649
|
+
cache_col = _fmt_tokens_compact(int(row.get("cache_read_tokens") or 0))
|
|
650
|
+
time_col = _fmt_duration_hms(duration, is_open=is_open)
|
|
651
|
+
status = str(row.get("status") or "")
|
|
652
|
+
note = " *unknown model" if unknown_model else ""
|
|
653
|
+
lines.append(
|
|
654
|
+
f"{stage:<9}{model:<11}{interactive:<5}{cost_col:<11}{est_col:<11}"
|
|
655
|
+
f"{out_col:<7}{cache_col:<8}{time_col:<10}{status}{note}"
|
|
656
|
+
)
|
|
657
|
+
return "\n".join(lines)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
# ── Cross-repo + time-bucketed rollup rendering (#1119 CLI-2) ────────────────
|
|
661
|
+
#
|
|
662
|
+
# Consumes coord.usage_rollup.aggregate() exactly like the #1115 renderers
|
|
663
|
+
# above — same ``~$``/4-decimal conventions, same plain-dict input. Two new
|
|
664
|
+
# views: a cross-cut rollup by repo/week/month (contract Mock 3) and a
|
|
665
|
+
# time-spent ranking by stage-type or issue (contract Mock 4).
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def format_usage_by_group(result: dict, window_label: str, dim: str) -> str:
|
|
669
|
+
"""Render ``coord usage --by repo|week|month`` (contract Mock 3 for
|
|
670
|
+
``dim="repo"``) from an :func:`coord.usage_rollup.aggregate` result.
|
|
671
|
+
|
|
672
|
+
Each row is one group (a repo name, or a week/month bucket string) with
|
|
673
|
+
its issue count (distinct ``issue_number`` across the group's rows),
|
|
674
|
+
legs, captured/estimated/total cost, tokens, and duration. Groups are
|
|
675
|
+
rendered in *result*'s given order — callers sort beforehand (see
|
|
676
|
+
``coord/commands/status.py``'s ``_usage_sort_key``).
|
|
677
|
+
"""
|
|
678
|
+
lines = [f"USAGE — by {dim} — window: {window_label}"]
|
|
679
|
+
lines.append(
|
|
680
|
+
f"{dim:<8}{'issues':>7}{'legs':>6} {'cost':<10} {'est(~)':<11} "
|
|
681
|
+
f"{'total':<11} {'out / cache':<20}time"
|
|
682
|
+
)
|
|
683
|
+
for g in result["groups"]:
|
|
684
|
+
key_str = str(g["key"])
|
|
685
|
+
issues = len({row.get("issue_number") for row in g["rows"]})
|
|
686
|
+
cost_str = _fmt_cost4(g["cost_captured"]) if g["cost_captured"] > 0 else "—"
|
|
687
|
+
est_str = _fmt_est4(g["cost_est"]) if g["cost_est"] > 0 else "—"
|
|
688
|
+
total_str = _fmt_cost4(g["cost_total"])
|
|
689
|
+
out_str = _fmt_tokens_compact(g["tokens"]["output"])
|
|
690
|
+
cache_str = _fmt_tokens_compact(g["tokens"]["cache_read"])
|
|
691
|
+
tok_str = f"{out_str} / {cache_str}"
|
|
692
|
+
dur_str = _fmt_duration_hms(g["duration_secs"], is_open=False)
|
|
693
|
+
lines.append(
|
|
694
|
+
f"{key_str:<8}{issues:>7}{g['legs']:>6} {cost_str:<10} {est_str:<11} "
|
|
695
|
+
f"{total_str:<11} {tok_str:<20}{dur_str}"
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
lines.append("─" * 80)
|
|
699
|
+
t = result["totals"]
|
|
700
|
+
total_out = _fmt_tokens_compact(t["tokens"]["output"])
|
|
701
|
+
total_cache = _fmt_tokens_compact(t["tokens"]["cache_read"])
|
|
702
|
+
total_dur = _fmt_duration_hms(t["duration_secs"], is_open=False)
|
|
703
|
+
progress = f" · {t['open_legs']} in progress" if t["open_legs"] else ""
|
|
704
|
+
lines.append(
|
|
705
|
+
f"Σ total {_fmt_cost4(t['cost_total'])} · {total_out} out / {total_cache} cache · "
|
|
706
|
+
f"{total_dur}{progress}"
|
|
707
|
+
)
|
|
708
|
+
return "\n".join(lines)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def format_usage_by_time(result: dict, window_label: str, dim: str) -> str:
|
|
712
|
+
"""Render ``coord usage --by-time`` (contract Mock 4) from an
|
|
713
|
+
:func:`coord.usage_rollup.aggregate` result with ``by="stage"`` (default,
|
|
714
|
+
``dim="stage"``) or ``by="issue"`` (``dim="issue"``, via ``--by issue``).
|
|
715
|
+
|
|
716
|
+
Ranks groups by share of total in-window active duration — "where is
|
|
717
|
+
wall-clock going." An open leg (no ``finished_at``) contributes 0
|
|
718
|
+
duration but is called out via the group's ``(N in progress)`` note.
|
|
719
|
+
"""
|
|
720
|
+
lines = [f"USAGE — time by {dim} — window: {window_label}"]
|
|
721
|
+
key_header = "issue" if dim == "issue" else "stage"
|
|
722
|
+
lines.append(f"{key_header:<14}{'legs':>4} {'time':<10}share")
|
|
723
|
+
|
|
724
|
+
total_dur = result["totals"]["duration_secs"]
|
|
725
|
+
for g in result["groups"]:
|
|
726
|
+
key_str = f"#{g['key']}" if dim == "issue" else str(g["key"])
|
|
727
|
+
dur_str = _fmt_duration_hms(g["duration_secs"], is_open=False)
|
|
728
|
+
pct = f"{(g['duration_secs'] / total_dur * 100):.1f}%" if total_dur > 0 else "—"
|
|
729
|
+
note = f" ({g['open_legs']} in progress)" if g["open_legs"] else ""
|
|
730
|
+
lines.append(f"{key_str:<14}{g['legs']:>4} {dur_str:<10}{pct}{note}")
|
|
731
|
+
|
|
732
|
+
lines.append(f"── total active {_fmt_duration_hms(total_dur, is_open=False)} ──")
|
|
733
|
+
if dim == "stage":
|
|
734
|
+
lines.append("(also available: --by-time --by issue → per-issue duration ranking)")
|
|
735
|
+
else:
|
|
736
|
+
lines.append("(also available: --by-time → time by stage)")
|
|
737
|
+
return "\n".join(lines)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def format_burn_rate_line(session: SessionUsage) -> str | None:
|
|
741
|
+
"""One-line burn-rate summary for ``coord status``.
|
|
742
|
+
|
|
743
|
+
Returns ``None`` when the burn rate is below the high threshold or
|
|
744
|
+
can't be computed (no session time).
|
|
745
|
+
"""
|
|
746
|
+
burn = session.burn_rate_usd_per_hour()
|
|
747
|
+
if burn is None or burn < HIGH_BURN_RATE_USD_PER_HOUR:
|
|
748
|
+
return None
|
|
749
|
+
total_str = _fmt_cost(session.total_cost_usd)
|
|
750
|
+
burn_str = _fmt_burn_rate(burn)
|
|
751
|
+
n = len(session.assignments)
|
|
752
|
+
noun = "assignment" if n == 1 else "assignments"
|
|
753
|
+
return f"Usage: {total_str} this session • burn rate: {burn_str} ⚠ ({n} {noun})"
|