diff --git a/CLAUDE.md b/CLAUDE.md index 991180cb..8975c4e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,8 @@ If you are an agent working in this repo: **do not improvise architecture**. Fol ### Current Focus: Phase 4A +**Phase 5.2 is complete** — Costs page now ships per-task and per-agent breakdowns (#558) on top of the spend summary (#557). Backend: `GET /api/v2/costs/tasks?days=N&limit=M` (top-N tasks with titles, agent, tokens, cost) and `GET /api/v2/costs/by-agent?days=N` (per-agent rollup + total input/output tokens), both via `TokenRepository.get_top_tasks_by_cost` and `get_costs_by_agent`. Task board cards show an inline `MoneyBag02Icon` cost badge with token-breakdown tooltip when cost data exists. Fixed a v2 data-loss bug where `react_agent` int-cast UUID task IDs and stored NULL in `token_usage`. + **Phase 5.1 is complete** — Settings page now ships three working tabs: Agent (#554), API Keys (#555), and PROOF9 Defaults + Workspace Config (#556). Backend: `GET/PUT /api/v2/proof/config` and `/api/v2/workspaces/config`, plus `run_proof()` now honors `enabled_gates` filtering and `strictness` (`strict` vs `warn`). Atomic JSON writes via `codeframe/ui/routers/_helpers.atomic_write_json`. The 9-gate canonical order and `proof_config.json` filename live in `codeframe/core/proof/models.py`. **Phase 3.5C is complete** — `CaptureGlitchModal` form (description/markdown, source, scope, gate obligations, severity, expiry) reachable from the PROOF9 page and the persistent sidebar "Capture Glitch" button. REQ detail view (`/proof/[req_id]`) ships markdown description rendering, `ProofScope` metadata display, obligations table with `Latest Run` column, sortable/filterable evidence history, and empty-state CTA. Backend: `ScopeOut` model on `RequirementResponse`. Issues #568, #569. diff --git a/codeframe/core/models.py b/codeframe/core/models.py index 9190fd5f..e4c223d6 100644 --- a/codeframe/core/models.py +++ b/codeframe/core/models.py @@ -879,7 +879,9 @@ class TokenUsage(BaseModel): """Token usage record for a single LLM call (Sprint 10).""" id: Optional[int] = None - task_id: Optional[int] = None # None for non-task calls + # Tasks use integer PKs in the v1 schema and UUID strings in v2 workspaces; + # SQLite is type-flexible, so we accept either at the model boundary. + task_id: Optional[Union[int, str]] = None # None for non-task calls agent_id: str project_id: int model_name: str = Field(..., description="e.g., claude-sonnet-4-5") diff --git a/codeframe/core/react_agent.py b/codeframe/core/react_agent.py index 4978824c..93e6c030 100644 --- a/codeframe/core/react_agent.py +++ b/codeframe/core/react_agent.py @@ -359,15 +359,19 @@ def _persist_token_usage(self, task_id: str) -> None: db.initialize() tracker = MetricsTracker(db=db) - # Cast task_id to int for the persistence layer (core uses str, DB uses int). + # v1 tasks have integer PKs; v2 workspaces use UUID strings. + # Pass the raw value — SQLite preserves the type, and downstream + # analytics (issue #558) group by whatever was stored. Forcing + # int() here used to drop every v2 record's task linkage. + persist_task_id: int | str try: - task_id_int: int | None = int(task_id) + persist_task_id = int(task_id) except (ValueError, TypeError): - task_id_int = None + persist_task_id = str(task_id) for record in self._token_records: tracker.record_token_usage_sync( - task_id=task_id_int, + task_id=persist_task_id, agent_id="react-agent", project_id=0, model_name=record["model"], diff --git a/codeframe/lib/metrics_tracker.py b/codeframe/lib/metrics_tracker.py index 2ee97268..8e80a743 100644 --- a/codeframe/lib/metrics_tracker.py +++ b/codeframe/lib/metrics_tracker.py @@ -40,7 +40,7 @@ import logging import re from datetime import datetime, timedelta, timezone -from typing import Dict, Any, List, Optional +from typing import Any, Dict, List, Optional, Union from codeframe.core.models import CallType, TokenUsage from codeframe.persistence.database import Database @@ -163,7 +163,7 @@ def calculate_cost(model_name: str, input_tokens: int, output_tokens: int) -> fl async def record_token_usage( self, - task_id: Optional[int], + task_id: Optional[Union[int, str]], agent_id: str, project_id: int, model_name: str, @@ -238,7 +238,7 @@ async def record_token_usage( def record_token_usage_sync( self, - task_id: Optional[int], + task_id: Optional[Union[int, str]], agent_id: str, project_id: int, model_name: str, diff --git a/codeframe/persistence/repositories/token_repository.py b/codeframe/persistence/repositories/token_repository.py index b7c38923..ab421405 100644 --- a/codeframe/persistence/repositories/token_repository.py +++ b/codeframe/persistence/repositories/token_repository.py @@ -351,6 +351,171 @@ def get_costs_summary(self, days: int) -> Dict[str, Any]: "daily": daily, } + def _window_iso_bounds(self, days: int) -> tuple[str, str]: + """Return inclusive start / exclusive end ISO strings for a `days` window. + + Mirrors get_costs_summary's bounds so the per-task and per-agent + aggregations cover the same rows. Space-separated, offset-free format + works against both ``CURRENT_TIMESTAMP`` defaults and ``.isoformat()``. + """ + if days <= 0: + raise ValueError("days must be a positive integer") + end_date = datetime.now(timezone.utc).date() + start_date = end_date - timedelta(days=days - 1) + start_iso = start_date.strftime("%Y-%m-%d %H:%M:%S") + end_iso = (end_date + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S") + return start_iso, end_iso + + def get_top_tasks_by_cost( + self, + days: int, + limit: int = 10, + ) -> List[Dict[str, Any]]: + """Aggregate spend per task and return the top N by cost. + + Args: + days: Trailing window in days. + limit: Maximum number of tasks to return. + + Returns: + List of dicts, sorted by total_cost_usd DESC: + { + "task_id": , + "agent_id": str, + "input_tokens": int, + "output_tokens": int, + "total_cost_usd": float, + } + Excludes rows where task_id IS NULL. The reported ``agent_id`` is + the agent that made the most calls for that task (ties broken + arbitrarily). ``task_id`` is returned as stored — SQLite preserves + the inserted type, so v2 UUID strings come back as strings and v1 + integers come back as integers. + """ + if limit <= 0: + raise ValueError("limit must be a positive integer") + start_iso, end_iso = self._window_iso_bounds(days) + + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT + task_id, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(estimated_cost_usd), 0.0) AS total_cost_usd + FROM token_usage + WHERE task_id IS NOT NULL + AND timestamp >= ? + AND timestamp < ? + GROUP BY task_id + ORDER BY total_cost_usd DESC + LIMIT ? + """, + (start_iso, end_iso, limit), + ) + rows = cursor.fetchall() + + # TODO(perf): the dominant-agent lookup is N+1 against the limit. + # Acceptable at limit=10 (analytics view) and even limit=1000 (badge + # map for a board). Fold into a single CTE if the cap grows further. + result: List[Dict[str, Any]] = [] + for row in rows: + task_id = row["task_id"] + # Find the most-used agent for this task in the same window. + cursor.execute( + """ + SELECT agent_id, COUNT(*) AS calls + FROM token_usage + WHERE task_id = ? + AND timestamp >= ? + AND timestamp < ? + GROUP BY agent_id + ORDER BY calls DESC + LIMIT 1 + """, + (task_id, start_iso, end_iso), + ) + agent_row = cursor.fetchone() + agent_id = agent_row["agent_id"] if agent_row else "" + + result.append({ + "task_id": task_id, + "agent_id": agent_id, + "input_tokens": int(row["input_tokens"] or 0), + "output_tokens": int(row["output_tokens"] or 0), + "total_cost_usd": float(row["total_cost_usd"] or 0.0), + }) + + return result + + def get_costs_by_agent(self, days: int) -> Dict[str, Any]: + """Aggregate spend per agent over a trailing `days` window. + + Args: + days: Trailing window in days. + + Returns: + { + "by_agent": [ + { + "agent_id": str, + "input_tokens": int, + "output_tokens": int, + "total_cost_usd": float, + "call_count": int, + }, + ... + ], + "total_input_tokens": int, + "total_output_tokens": int, + } + + Includes records with NULL ``task_id`` — calls without a task still + attribute to an agent. Sorted by total_cost_usd DESC. + """ + start_iso, end_iso = self._window_iso_bounds(days) + + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT + agent_id, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(estimated_cost_usd), 0.0) AS total_cost_usd, + COUNT(*) AS call_count + FROM token_usage + WHERE timestamp >= ? AND timestamp < ? + GROUP BY agent_id + ORDER BY total_cost_usd DESC + """, + (start_iso, end_iso), + ) + rows = cursor.fetchall() + + by_agent: List[Dict[str, Any]] = [] + total_input = 0 + total_output = 0 + for row in rows: + inp = int(row["input_tokens"] or 0) + out = int(row["output_tokens"] or 0) + by_agent.append({ + "agent_id": row["agent_id"], + "input_tokens": inp, + "output_tokens": out, + "total_cost_usd": float(row["total_cost_usd"] or 0.0), + "call_count": int(row["call_count"] or 0), + }) + total_input += inp + total_output += out + + return { + "by_agent": by_agent, + "total_input_tokens": total_input, + "total_output_tokens": total_output, + } + def get_project_costs_aggregate(self, project_id: int) -> Dict[str, Any]: """Get aggregated cost statistics for a project. diff --git a/codeframe/ui/routers/costs_v2.py b/codeframe/ui/routers/costs_v2.py index c516606c..0b125a3a 100644 --- a/codeframe/ui/routers/costs_v2.py +++ b/codeframe/ui/routers/costs_v2.py @@ -17,11 +17,12 @@ import logging import sqlite3 from datetime import datetime, timedelta, timezone -from typing import Dict, List +from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel +from codeframe.core import tasks as tasks_module from codeframe.core.workspace import Workspace from codeframe.lib.rate_limiter import rate_limit_standard from codeframe.persistence.repositories.token_repository import TokenRepository @@ -128,3 +129,194 @@ async def get_costs_summary( for d in summary["daily"] ], ) + + +# --------------------------------------------------------------------------- +# Per-task and per-agent breakdowns (Issue #558) +# --------------------------------------------------------------------------- + + +class TaskCostEntry(BaseModel): + """One task's aggregated cost with the most-used agent.""" + + task_id: str + task_title: str + agent_id: str + input_tokens: int + output_tokens: int + total_cost_usd: float + + +class TaskCostsResponse(BaseModel): + """Top-N tasks by cost over the requested window.""" + + tasks: List[TaskCostEntry] + + +class AgentCostEntry(BaseModel): + """One agent's aggregated cost over the window.""" + + agent_id: str + input_tokens: int + output_tokens: int + total_cost_usd: float + call_count: int + + +class AgentCostsResponse(BaseModel): + """Per-agent breakdown plus overall token totals.""" + + by_agent: List[AgentCostEntry] + total_input_tokens: int + total_output_tokens: int + + +def _placeholder_task_title(task_id: str) -> str: + """Title to display when a task referenced by token_usage no longer exists.""" + short = str(task_id)[:8] if task_id else "unknown" + return f"Unknown task ({short})" + + +def _open_workspace_conn(db_path: str) -> Optional[sqlite3.Connection]: + """Open the workspace DB or return None if it cannot be read. + + Mirrors _query_costs's tolerance for fresh/locked workspaces: callers + fall back to an empty response rather than 500'ing the dashboard. + """ + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + return conn + except sqlite3.Error as e: + logger.warning("costs: failed to open %s: %s", db_path, e) + return None + + +def _token_usage_exists(conn: sqlite3.Connection) -> bool: + cursor = conn.cursor() + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage'" + ) + return cursor.fetchone() is not None + + +def _query_top_tasks( + db_path: str, workspace: Workspace, days: int, limit: int, +) -> List[Dict[str, Any]]: + """Aggregate per-task cost and join titles via workspace.tasks. + + Returns a list of dicts ready for serialization into ``TaskCostEntry``. + """ + conn = _open_workspace_conn(db_path) + if conn is None: + return [] + + try: + if not _token_usage_exists(conn): + return [] + try: + repo = TokenRepository(sync_conn=conn) + rows = repo.get_top_tasks_by_cost(days=days, limit=limit) + except sqlite3.Error as e: + logger.warning("costs/tasks: query failed on %s: %s", db_path, e) + return [] + finally: + conn.close() + + entries: List[Dict[str, Any]] = [] + for row in rows: + raw_id = row["task_id"] + task_id_str = str(raw_id) if raw_id is not None else "" + title = _placeholder_task_title(task_id_str) + try: + task = tasks_module.get(workspace, task_id_str) + if task is not None: + title = task.title + except Exception: + # Lookup failures are non-fatal — keep the placeholder title. + logger.debug("costs/tasks: task lookup failed for %s", task_id_str, exc_info=True) + + entries.append({ + "task_id": task_id_str, + "task_title": title, + "agent_id": row["agent_id"], + "input_tokens": row["input_tokens"], + "output_tokens": row["output_tokens"], + "total_cost_usd": row["total_cost_usd"], + }) + + return entries + + +def _query_costs_by_agent(db_path: str, days: int) -> Dict[str, Any]: + """Aggregate per-agent cost over the window.""" + empty = {"by_agent": [], "total_input_tokens": 0, "total_output_tokens": 0} + + conn = _open_workspace_conn(db_path) + if conn is None: + return empty + + try: + if not _token_usage_exists(conn): + return empty + try: + repo = TokenRepository(sync_conn=conn) + return repo.get_costs_by_agent(days=days) + except sqlite3.Error as e: + logger.warning("costs/by-agent: query failed on %s: %s", db_path, e) + return empty + finally: + conn.close() + + +@router.get("/tasks", response_model=TaskCostsResponse) +@rate_limit_standard() +async def get_costs_by_task( + request: Request, + workspace: Workspace = Depends(get_v2_workspace), + days: int = Query(30, ge=1, le=365, description="Window size in days (1-365)"), + limit: int = Query( + 10, + ge=1, + le=1000, + description=( + "Max number of tasks to return. Default 10 matches the analytics view; " + "raise it (e.g. to 1000) when populating a per-task badge map for the " + "full task board." + ), + ), +): + """Return the top ``limit`` tasks by total cost over the requested window. + + Token usage rows are grouped by ``task_id``; the resulting list is sorted + by total cost (descending). Rows whose ``task_id`` is NULL are excluded — + only task-attributable spend counts here. + + If the workspace has no token usage data yet (or the table doesn't exist), + returns ``{"tasks": []}`` rather than an error. + """ + entries = _query_top_tasks(str(workspace.db_path), workspace, days, limit=limit) + return TaskCostsResponse( + tasks=[TaskCostEntry(**e) for e in entries], + ) + + +@router.get("/by-agent", response_model=AgentCostsResponse) +@rate_limit_standard() +async def get_costs_by_agent_endpoint( + request: Request, + workspace: Workspace = Depends(get_v2_workspace), + days: int = Query(30, ge=1, le=365, description="Window size in days (1-365)"), +): + """Return per-agent cost breakdown and overall input/output token totals. + + Token usage rows are grouped by ``agent_id`` and sorted by total cost + (descending). Rows with NULL ``task_id`` still count toward the agent's + totals (a non-task call still represents spend). + """ + summary = _query_costs_by_agent(str(workspace.db_path), days) + return AgentCostsResponse( + by_agent=[AgentCostEntry(**a) for a in summary["by_agent"]], + total_input_tokens=summary["total_input_tokens"], + total_output_tokens=summary["total_output_tokens"], + ) diff --git a/docs/PRODUCT_ROADMAP.md b/docs/PRODUCT_ROADMAP.md index c8de41d7..03bc126f 100644 --- a/docs/PRODUCT_ROADMAP.md +++ b/docs/PRODUCT_ROADMAP.md @@ -195,7 +195,7 @@ These are items that were considered and excluded because they do not serve the | 4A | PR status + PROOF9 merge gate | ❌ Not started | — | | 4B | Post-merge glitch capture loop | ❌ Not started | — | | 5.1 | Settings page (skeleton + agent config + PROOF9/workspace tabs) | ✅ Complete | #554–556 | -| 5.2 | Cost analytics | ❌ Not started | #557–558 | +| 5.2 | Cost analytics | ✅ Complete | #557–558 | | 5.3 | Async notifications | ❌ Not started | #559–560 | | 5.4 | PRD stress-test web UI | ❌ Not started | #561–562 | | 5.5 | GitHub Issues import | ❌ Not started | #563–565 | diff --git a/tests/persistence/test_token_repository_costs.py b/tests/persistence/test_token_repository_costs.py index df7d4893..2cff7a3f 100644 --- a/tests/persistence/test_token_repository_costs.py +++ b/tests/persistence/test_token_repository_costs.py @@ -198,3 +198,191 @@ class TestGetCostsSummaryRangeValidation: def test_valid_ranges(self, db, days): summary = db.token_usage.get_costs_summary(days=days) assert len(summary["daily"]) == days + + +# --------------------------------------------------------------------------- +# get_top_tasks_by_cost (Issue #558) — per-task cost breakdown +# --------------------------------------------------------------------------- + + +def _save_with_agent( + db, task_id, cost, agent_id="agent-001", project_id=1, timestamp=None, + input_tokens=100, output_tokens=50, +): + if timestamp is None: + timestamp = datetime.now(timezone.utc) + usage = TokenUsage( + task_id=task_id, + agent_id=agent_id, + project_id=project_id, + model_name="claude-sonnet-4-5", + input_tokens=input_tokens, + output_tokens=output_tokens, + estimated_cost_usd=cost, + actual_cost_usd=None, + call_type=CallType.TASK_EXECUTION, + timestamp=timestamp, + ) + return db.save_token_usage(usage) + + +class TestGetTopTasksByCost: + def test_empty_returns_empty_list(self, db): + result = db.token_usage.get_top_tasks_by_cost(days=30) + assert result == [] + + def test_aggregates_cost_per_task(self, db): + t1 = _create_task(db) + t2 = _create_task(db) + _save_with_agent(db, task_id=t1, cost=0.25) + _save_with_agent(db, task_id=t1, cost=0.50) + _save_with_agent(db, task_id=t2, cost=0.10) + + result = db.token_usage.get_top_tasks_by_cost(days=30) + + # Sorted by cost desc + assert len(result) == 2 + assert result[0]["task_id"] == t1 + assert result[0]["total_cost_usd"] == pytest.approx(0.75) + assert result[0]["input_tokens"] == 200 + assert result[0]["output_tokens"] == 100 + assert result[1]["task_id"] == t2 + assert result[1]["total_cost_usd"] == pytest.approx(0.10) + + def test_includes_most_used_agent_per_task(self, db): + """Task aggregates should report the agent with the most calls.""" + t1 = _create_task(db) + _save_with_agent(db, task_id=t1, cost=0.10, agent_id="react-agent") + _save_with_agent(db, task_id=t1, cost=0.10, agent_id="react-agent") + _save_with_agent(db, task_id=t1, cost=0.10, agent_id="other-agent") + + result = db.token_usage.get_top_tasks_by_cost(days=30) + + assert result[0]["agent_id"] == "react-agent" + + def test_excludes_null_task_ids(self, db): + t1 = _create_task(db) + _save_with_agent(db, task_id=t1, cost=0.10) + _save_with_agent(db, task_id=None, cost=99.0) + + result = db.token_usage.get_top_tasks_by_cost(days=30) + + assert len(result) == 1 + assert result[0]["task_id"] == t1 + + def test_respects_limit(self, db): + for _ in range(15): + tid = _create_task(db) + _save_with_agent(db, task_id=tid, cost=0.01) + + result = db.token_usage.get_top_tasks_by_cost(days=30, limit=10) + assert len(result) == 10 + + def test_excludes_data_outside_window(self, db): + t1 = _create_task(db) + now = datetime.now(timezone.utc) + _save_with_agent(db, task_id=t1, cost=0.10, timestamp=now) + _save_with_agent(db, task_id=t1, cost=99.0, timestamp=now - timedelta(days=100)) + + result = db.token_usage.get_top_tasks_by_cost(days=30) + + assert result[0]["total_cost_usd"] == pytest.approx(0.10) + + def test_supports_text_task_ids(self, db): + """SQLite is type-flexible: TEXT (UUID) task_ids must be aggregated correctly. + + v2 workspaces store task UUIDs in the same INTEGER-declared column; the + aggregation has to group by the raw value without type coercion. The v1 + Database fixture enforces FK(token_usage.task_id → tasks.id), so we + relax it for this test to model the v2 schema where token_usage has no + such constraint. + """ + uuid_a = "task-uuid-aaaa" + uuid_b = "task-uuid-bbbb" + cursor = db.conn.cursor() + cursor.execute("PRAGMA foreign_keys = OFF") + try: + for tid, cost in [(uuid_a, 0.50), (uuid_a, 0.25), (uuid_b, 0.10)]: + cursor.execute( + """ + INSERT INTO token_usage (task_id, agent_id, project_id, model_name, + input_tokens, output_tokens, estimated_cost_usd, call_type, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (tid, "react-agent", 1, "claude-sonnet-4-5", + 100, 50, cost, "task_execution", + datetime.now(timezone.utc).isoformat()), + ) + db.conn.commit() + finally: + cursor.execute("PRAGMA foreign_keys = ON") + + result = db.token_usage.get_top_tasks_by_cost(days=30) + + assert len(result) == 2 + assert result[0]["task_id"] == uuid_a + assert result[0]["total_cost_usd"] == pytest.approx(0.75) + + +# --------------------------------------------------------------------------- +# get_costs_by_agent (Issue #558) — per-agent cost breakdown +# --------------------------------------------------------------------------- + + +class TestGetCostsByAgent: + def test_empty_returns_zero_state(self, db): + result = db.token_usage.get_costs_by_agent(days=30) + assert result["by_agent"] == [] + assert result["total_input_tokens"] == 0 + assert result["total_output_tokens"] == 0 + + def test_aggregates_cost_per_agent(self, db): + t1 = _create_task(db) + _save_with_agent(db, task_id=t1, cost=0.30, agent_id="claude-code") + _save_with_agent(db, task_id=t1, cost=0.20, agent_id="claude-code") + _save_with_agent(db, task_id=t1, cost=0.40, agent_id="codex") + + result = db.token_usage.get_costs_by_agent(days=30) + + # Sorted by cost desc + agents = result["by_agent"] + assert len(agents) == 2 + assert agents[0]["agent_id"] == "claude-code" + assert agents[0]["total_cost_usd"] == pytest.approx(0.50) + assert agents[0]["call_count"] == 2 + assert agents[0]["input_tokens"] == 200 + assert agents[0]["output_tokens"] == 100 + assert agents[1]["agent_id"] == "codex" + assert agents[1]["total_cost_usd"] == pytest.approx(0.40) + + def test_includes_null_task_records(self, db): + """Per-agent totals should include calls not linked to a task.""" + _save_with_agent(db, task_id=None, cost=0.10, agent_id="solo-agent") + + result = db.token_usage.get_costs_by_agent(days=30) + + assert len(result["by_agent"]) == 1 + assert result["by_agent"][0]["agent_id"] == "solo-agent" + + def test_totals_match_sum_of_agents(self, db): + t1 = _create_task(db) + _save_with_agent(db, task_id=t1, cost=0.10, + agent_id="a", input_tokens=100, output_tokens=50) + _save_with_agent(db, task_id=t1, cost=0.10, + agent_id="b", input_tokens=200, output_tokens=75) + + result = db.token_usage.get_costs_by_agent(days=30) + + assert result["total_input_tokens"] == 300 + assert result["total_output_tokens"] == 125 + + def test_excludes_data_outside_window(self, db): + t1 = _create_task(db) + now = datetime.now(timezone.utc) + _save_with_agent(db, task_id=t1, cost=0.10, agent_id="a", timestamp=now) + _save_with_agent(db, task_id=t1, cost=99.0, agent_id="a", + timestamp=now - timedelta(days=100)) + + result = db.token_usage.get_costs_by_agent(days=30) + + assert result["by_agent"][0]["total_cost_usd"] == pytest.approx(0.10) diff --git a/tests/ui/test_costs_v2.py b/tests/ui/test_costs_v2.py index e7876261..e7817d7b 100644 --- a/tests/ui/test_costs_v2.py +++ b/tests/ui/test_costs_v2.py @@ -10,7 +10,7 @@ import shutil import sqlite3 import tempfile -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path import pytest @@ -159,3 +159,165 @@ def test_valid_ranges_accepted(self, test_client, days): response = test_client.get(f"/api/v2/costs/summary?days={days}") assert response.status_code == 200 assert len(response.json()["daily"]) == days + + +# --------------------------------------------------------------------------- +# /api/v2/costs/tasks (Issue #558) +# --------------------------------------------------------------------------- + + +def _record_usage_text_task( + workspace, *, task_id, cost=0.10, agent_id="react-agent", + input_tokens=100, output_tokens=50, when=None, +): + """Insert a token_usage record with a TEXT (v2 UUID) task_id.""" + _ensure_token_usage_table(workspace.db_path) + timestamp = (when or datetime.now(timezone.utc)).isoformat() + conn = sqlite3.connect(str(workspace.db_path)) + try: + conn.execute("PRAGMA foreign_keys = OFF") + conn.execute( + """ + INSERT INTO token_usage ( + task_id, agent_id, project_id, model_name, + input_tokens, output_tokens, estimated_cost_usd, + call_type, timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (task_id, agent_id, 1, "claude-sonnet-4-5", + input_tokens, output_tokens, cost, "task_execution", timestamp), + ) + conn.commit() + finally: + conn.close() + + +class TestCostsTasksEmpty: + def test_returns_empty_list(self, test_client): + response = test_client.get("/api/v2/costs/tasks") + assert response.status_code == 200 + body = response.json() + assert body == {"tasks": []} + + +class TestCostsTasksWithData: + def test_returns_top_tasks_with_titles(self, test_client): + """Tasks present in the workspace are joined to their titles.""" + from codeframe.core import tasks as tasks_module + + workspace = test_client.workspace + task = tasks_module.create( + workspace, title="Implement search", description="..." + ) + _record_usage_text_task(workspace, task_id=task.id, cost=0.50) + _record_usage_text_task(workspace, task_id=task.id, cost=0.25) + + response = test_client.get("/api/v2/costs/tasks") + assert response.status_code == 200 + tasks_list = response.json()["tasks"] + assert len(tasks_list) == 1 + entry = tasks_list[0] + assert entry["task_id"] == task.id + assert entry["task_title"] == "Implement search" + assert entry["agent_id"] == "react-agent" + assert entry["input_tokens"] == 200 + assert entry["output_tokens"] == 100 + assert entry["total_cost_usd"] == pytest.approx(0.75) + + def test_missing_task_falls_back_to_placeholder_title(self, test_client): + """When token_usage references a task that no longer exists, + the response still includes the row with a synthesized title.""" + workspace = test_client.workspace + _record_usage_text_task(workspace, task_id="orphan-uuid", cost=0.10) + + response = test_client.get("/api/v2/costs/tasks") + body = response.json() + assert len(body["tasks"]) == 1 + assert body["tasks"][0]["task_id"] == "orphan-uuid" + assert "orphan-uu" in body["tasks"][0]["task_title"].lower() or \ + "unknown" in body["tasks"][0]["task_title"].lower() + + def test_caps_at_10_tasks(self, test_client): + from codeframe.core import tasks as tasks_module + workspace = test_client.workspace + for i in range(15): + t = tasks_module.create(workspace, title=f"T{i}", description="") + _record_usage_text_task(workspace, task_id=t.id, cost=0.01 * (i + 1)) + + response = test_client.get("/api/v2/costs/tasks") + assert len(response.json()["tasks"]) == 10 + + def test_days_param_filters_window(self, test_client): + from codeframe.core import tasks as tasks_module + workspace = test_client.workspace + t = tasks_module.create(workspace, title="Recent", description="") + now = datetime.now(timezone.utc) + _record_usage_text_task(workspace, task_id=t.id, cost=0.10, when=now) + _record_usage_text_task( + workspace, task_id=t.id, cost=99.0, + when=now - timedelta(days=60), + ) + + response = test_client.get("/api/v2/costs/tasks?days=30") + assert response.json()["tasks"][0]["total_cost_usd"] == pytest.approx(0.10) + + +# --------------------------------------------------------------------------- +# /api/v2/costs/by-agent (Issue #558) +# --------------------------------------------------------------------------- + + +class TestCostsByAgentEmpty: + def test_returns_zero_state(self, test_client): + response = test_client.get("/api/v2/costs/by-agent") + assert response.status_code == 200 + body = response.json() + assert body == { + "by_agent": [], + "total_input_tokens": 0, + "total_output_tokens": 0, + } + + +class TestCostsByAgentWithData: + def test_aggregates_by_agent(self, test_client): + workspace = test_client.workspace + _record_usage_text_task( + workspace, task_id="t1", agent_id="claude-code", + cost=0.30, input_tokens=100, output_tokens=50, + ) + _record_usage_text_task( + workspace, task_id="t1", agent_id="claude-code", + cost=0.20, input_tokens=200, output_tokens=100, + ) + _record_usage_text_task( + workspace, task_id="t2", agent_id="codex", + cost=0.10, input_tokens=50, output_tokens=25, + ) + + response = test_client.get("/api/v2/costs/by-agent") + body = response.json() + + assert body["total_input_tokens"] == 350 + assert body["total_output_tokens"] == 175 + + agents = body["by_agent"] + assert len(agents) == 2 + assert agents[0]["agent_id"] == "claude-code" + assert agents[0]["total_cost_usd"] == pytest.approx(0.50) + assert agents[0]["call_count"] == 2 + assert agents[1]["agent_id"] == "codex" + + +class TestCostsTasksDaysValidation: + def test_below_minimum_rejected(self, test_client): + response = test_client.get("/api/v2/costs/tasks?days=0") + assert response.status_code == 422 + + def test_above_maximum_rejected(self, test_client): + response = test_client.get("/api/v2/costs/tasks?days=400") + assert response.status_code == 422 + + def test_by_agent_below_minimum_rejected(self, test_client): + response = test_client.get("/api/v2/costs/by-agent?days=0") + assert response.status_code == 422 diff --git a/web-ui/__tests__/components/tasks/TaskCard.test.tsx b/web-ui/__tests__/components/tasks/TaskCard.test.tsx index 362accbf..a54a6a39 100644 --- a/web-ui/__tests__/components/tasks/TaskCard.test.tsx +++ b/web-ui/__tests__/components/tasks/TaskCard.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { TaskCard } from '@/components/tasks/TaskCard'; -import type { Task } from '@/types'; +import type { Task, TaskCostEntry } from '@/types'; // ─── Fixtures ─────────────────────────────────────────────────────── @@ -227,4 +227,68 @@ describe('TaskCard', () => { expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument(); expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument(); }); + + // ─── Cost badge (issue #558) ───────────────────────────────────────── + + function makeCostMap(entries: Partial[]): Map { + const map = new Map(); + for (const entry of entries) { + const full: TaskCostEntry = { + task_id: entry.task_id ?? 'task-1', + task_title: entry.task_title ?? 'Implement login', + agent_id: entry.agent_id ?? 'react-agent', + input_tokens: entry.input_tokens ?? 1000, + output_tokens: entry.output_tokens ?? 500, + total_cost_usd: entry.total_cost_usd ?? 0.12, + }; + map.set(full.task_id, full); + } + return map; + } + + it('renders a cost badge when cost data exists for the task', () => { + renderCard({}, { + costMap: makeCostMap([{ task_id: 'task-1', total_cost_usd: 0.1234 }]), + }); + const badge = screen.getByTestId('cost-badge'); + expect(badge).toBeInTheDocument(); + expect(badge.textContent).toContain('$0.12'); + }); + + it('hides the cost badge when no cost data exists for the task', () => { + renderCard({}, { + costMap: makeCostMap([{ task_id: 'other-task', total_cost_usd: 0.99 }]), + }); + expect(screen.queryByTestId('cost-badge')).not.toBeInTheDocument(); + }); + + it('hides the cost badge when cost is zero', () => { + renderCard({}, { + costMap: makeCostMap([{ task_id: 'task-1', total_cost_usd: 0 }]), + }); + expect(screen.queryByTestId('cost-badge')).not.toBeInTheDocument(); + }); + + it('hides the cost badge when costMap is undefined', () => { + renderCard(); + expect(screen.queryByTestId('cost-badge')).not.toBeInTheDocument(); + }); + + it('formats cost above one dollar with two decimals', () => { + renderCard({}, { + costMap: makeCostMap([{ task_id: 'task-1', total_cost_usd: 12.5 }]), + }); + expect(screen.getByTestId('cost-badge').textContent).toContain('$12.50'); + }); + + it('shows sub-cent costs at four-decimal precision', () => { + // Regression: $0.0042 must not collapse to $0.00 just because the + // badge is visible. Matches TopTasksTable's precision. + renderCard({}, { + costMap: makeCostMap([{ task_id: 'task-1', total_cost_usd: 0.0042 }]), + }); + const text = screen.getByTestId('cost-badge').textContent ?? ''; + expect(text).toContain('$0.0042'); + expect(text).not.toMatch(/\$0\.00\b/); + }); }); diff --git a/web-ui/src/__tests__/components/costs/AgentCostBars.test.tsx b/web-ui/src/__tests__/components/costs/AgentCostBars.test.tsx new file mode 100644 index 00000000..897b6712 --- /dev/null +++ b/web-ui/src/__tests__/components/costs/AgentCostBars.test.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { AgentCostBars } from '@/components/costs/AgentCostBars'; +import type { AgentCostsResponse } from '@/types'; + +function makeData(overrides: Partial = {}): AgentCostsResponse { + return { + by_agent: [ + { + agent_id: 'react-agent', + input_tokens: 800, + output_tokens: 400, + total_cost_usd: 0.50, + call_count: 5, + }, + { + agent_id: 'codex', + input_tokens: 200, + output_tokens: 100, + total_cost_usd: 0.10, + call_count: 1, + }, + ], + total_input_tokens: 1000, + total_output_tokens: 500, + ...overrides, + }; +} + +describe('AgentCostBars', () => { + it('renders an empty state when no agents have cost data', () => { + render( + + ); + expect(screen.getByTestId('agent-bars-empty')).toBeInTheDocument(); + }); + + it('renders a loading skeleton when isLoading and no data', () => { + render( + + ); + expect(screen.getByTestId('agent-bars-loading')).toBeInTheDocument(); + }); + + it('renders one row per agent with a progressbar bar', () => { + render(); + expect(screen.getByTestId('agent-bars')).toBeInTheDocument(); + expect(screen.getByTestId('agent-row-react-agent')).toBeInTheDocument(); + expect(screen.getByTestId('agent-row-codex')).toBeInTheDocument(); + + // Each bar exposes role="progressbar" + const bars = screen.getAllByRole('progressbar'); + expect(bars).toHaveLength(2); + }); + + it('shows the input/output token split with computed percentage', () => { + render(); + const split = screen.getByTestId('token-split'); + // 1000 / (1000+500) = 66.7% -> rounds to 67% + expect(split.textContent).toContain('1,000'); + expect(split.textContent).toContain('500'); + expect(split.textContent).toMatch(/67%/); + }); + + it('handles zero totals without dividing by zero', () => { + render( + + ); + const split = screen.getByTestId('token-split'); + expect(split.textContent).toMatch(/0%/); + }); +}); diff --git a/web-ui/src/__tests__/components/costs/CostsPage.test.tsx b/web-ui/src/__tests__/components/costs/CostsPage.test.tsx index 1336a4af..2cd86498 100644 --- a/web-ui/src/__tests__/components/costs/CostsPage.test.tsx +++ b/web-ui/src/__tests__/components/costs/CostsPage.test.tsx @@ -3,7 +3,11 @@ import { render, screen, fireEvent } from '@testing-library/react'; import useSWR from 'swr'; import CostsPage from '@/app/costs/page'; import * as storage from '@/lib/workspace-storage'; -import type { CostSummaryResponse } from '@/types'; +import type { + CostSummaryResponse, + TaskCostsResponse, + AgentCostsResponse, +} from '@/types'; jest.mock('swr'); jest.mock('@/lib/workspace-storage', () => ({ @@ -13,6 +17,8 @@ jest.mock('@/lib/workspace-storage', () => ({ jest.mock('@/lib/api', () => ({ costsApi: { getSummary: jest.fn(), + getTopTasks: jest.fn(), + getByAgent: jest.fn(), }, workspaceApi: { checkExists: jest.fn(), @@ -27,6 +33,26 @@ jest.mock('@/components/costs/SpendBarChart', () => ({
), })); +jest.mock('@/components/costs/TopTasksTable', () => ({ + TopTasksTable: ({ tasks, isLoading }: { tasks: unknown[]; isLoading?: boolean }) => ( +
+ ), +})); +jest.mock('@/components/costs/AgentCostBars', () => ({ + AgentCostBars: ({ data, isLoading }: { data: AgentCostsResponse; isLoading?: boolean }) => ( +
+ ), +})); const mockUseSWR = useSWR as jest.MockedFunction; const mockGetWorkspace = storage.getSelectedWorkspacePath as jest.MockedFunction< @@ -48,6 +74,76 @@ function makeSummary(overrides: Partial = {}): CostSummaryR }; } +function makeTopTasks(overrides: Partial = {}): TaskCostsResponse { + return { + tasks: [ + { + task_id: 't-1', + task_title: 'Build login', + agent_id: 'react-agent', + input_tokens: 1000, + output_tokens: 500, + total_cost_usd: 0.42, + }, + ], + ...overrides, + }; +} + +function makeByAgent(overrides: Partial = {}): AgentCostsResponse { + return { + by_agent: [ + { + agent_id: 'claude-code', + input_tokens: 800, + output_tokens: 400, + total_cost_usd: 0.30, + call_count: 2, + }, + ], + total_input_tokens: 1000, + total_output_tokens: 500, + ...overrides, + }; +} + +/** + * Set up useSWR mock to return different data based on cache key. + * Page passes a key like ['/api/v2/costs/summary', workspace, days]. + */ +function setupSwr(opts: { + summary?: CostSummaryResponse | undefined; + tasks?: TaskCostsResponse | undefined; + byAgent?: AgentCostsResponse | undefined; + error?: { detail: string; status_code: number }; + isLoading?: boolean; +}) { + mockUseSWR.mockImplementation((key: unknown) => { + const arr = Array.isArray(key) ? key : []; + const path = arr[0] as string | undefined; + if (path === '/api/v2/costs/tasks') { + return { + data: opts.tasks, + error: undefined, + isLoading: opts.isLoading ?? false, + } as ReturnType; + } + if (path === '/api/v2/costs/by-agent') { + return { + data: opts.byAgent, + error: undefined, + isLoading: opts.isLoading ?? false, + } as ReturnType; + } + // summary endpoint (or null key when no workspace selected) + return { + data: opts.summary, + error: opts.error, + isLoading: opts.isLoading ?? false, + } as ReturnType; + }); +} + describe('CostsPage', () => { beforeEach(() => { mockGetWorkspace.mockReturnValue(WORKSPACE); @@ -56,21 +152,13 @@ describe('CostsPage', () => { it('shows the workspace selector when no workspace is set', () => { mockGetWorkspace.mockReturnValue(null); - mockUseSWR.mockReturnValue({ - data: undefined, - error: undefined, - isLoading: false, - } as ReturnType); + setupSwr({}); render(); expect(screen.getByTestId('workspace-selector')).toBeInTheDocument(); }); it('renders summary cards from the API response', () => { - mockUseSWR.mockReturnValue({ - data: makeSummary(), - error: undefined, - isLoading: false, - } as ReturnType); + setupSwr({ summary: makeSummary(), tasks: makeTopTasks(), byAgent: makeByAgent() }); render(); expect(screen.getByTestId('total-spend')).toHaveTextContent('$1.2345'); expect(screen.getByTestId('total-tasks')).toHaveTextContent('4'); @@ -78,11 +166,7 @@ describe('CostsPage', () => { }); it('renders the spend chart with the daily series and days prop', () => { - mockUseSWR.mockReturnValue({ - data: makeSummary(), - error: undefined, - isLoading: false, - } as ReturnType); + setupSwr({ summary: makeSummary(), tasks: makeTopTasks(), byAgent: makeByAgent() }); render(); const chart = screen.getByTestId('spend-chart-mock'); expect(chart.getAttribute('data-days')).toBe('30'); @@ -90,11 +174,7 @@ describe('CostsPage', () => { }); it('updates the time range when the selector changes', () => { - mockUseSWR.mockReturnValue({ - data: makeSummary(), - error: undefined, - isLoading: false, - } as ReturnType); + setupSwr({ summary: makeSummary(), tasks: makeTopTasks(), byAgent: makeByAgent() }); render(); const select = screen.getByTestId('time-range-select') as HTMLSelectElement; expect(select.value).toBe('30'); @@ -107,22 +187,46 @@ describe('CostsPage', () => { }); it('shows the loading skeleton when no data has arrived yet', () => { - mockUseSWR.mockReturnValue({ - data: undefined, - error: undefined, - isLoading: true, - } as ReturnType); + setupSwr({ isLoading: true }); render(); expect(screen.getByTestId('costs-loading')).toBeInTheDocument(); }); it('shows an error banner on fetch failure', () => { - mockUseSWR.mockReturnValue({ - data: undefined, - error: { detail: 'Boom', status_code: 500 }, - isLoading: false, - } as ReturnType); + setupSwr({ error: { detail: 'Boom', status_code: 500 } }); render(); expect(screen.getByTestId('costs-error')).toHaveTextContent('Boom'); }); + + // ─── Issue #558 sections ───────────────────────────────────────────── + + it('renders the top tasks section with data from the costs/tasks endpoint', () => { + setupSwr({ summary: makeSummary(), tasks: makeTopTasks(), byAgent: makeByAgent() }); + render(); + const top = screen.getByTestId('top-tasks-mock'); + expect(top.getAttribute('data-count')).toBe('1'); + expect(screen.getByRole('heading', { name: /top tasks by cost/i })).toBeInTheDocument(); + }); + + it('renders the per-agent section with totals', () => { + setupSwr({ summary: makeSummary(), tasks: makeTopTasks(), byAgent: makeByAgent() }); + render(); + const agents = screen.getByTestId('agent-bars-mock'); + expect(agents.getAttribute('data-agents')).toBe('1'); + expect(agents.getAttribute('data-input')).toBe('1000'); + expect(agents.getAttribute('data-output')).toBe('500'); + expect(screen.getByRole('heading', { name: /cost by agent/i })).toBeInTheDocument(); + }); + + it('passes a zero-state fallback to AgentCostBars when no data has loaded yet', () => { + setupSwr({ + summary: makeSummary(), + tasks: makeTopTasks(), + byAgent: undefined, + }); + render(); + const agents = screen.getByTestId('agent-bars-mock'); + expect(agents.getAttribute('data-agents')).toBe('0'); + expect(agents.getAttribute('data-input')).toBe('0'); + }); }); diff --git a/web-ui/src/__tests__/components/costs/TopTasksTable.test.tsx b/web-ui/src/__tests__/components/costs/TopTasksTable.test.tsx new file mode 100644 index 00000000..fca62120 --- /dev/null +++ b/web-ui/src/__tests__/components/costs/TopTasksTable.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TopTasksTable } from '@/components/costs/TopTasksTable'; +import type { TaskCostEntry } from '@/types'; + +function makeEntry(overrides: Partial = {}): TaskCostEntry { + return { + task_id: 't-1', + task_title: 'Build login flow', + agent_id: 'react-agent', + input_tokens: 1234, + output_tokens: 567, + total_cost_usd: 0.4321, + ...overrides, + }; +} + +describe('TopTasksTable', () => { + it('renders an empty state when no tasks have cost data', () => { + render(); + expect(screen.getByTestId('top-tasks-empty')).toBeInTheDocument(); + }); + + it('renders a loading skeleton when isLoading is true and no data', () => { + render(); + expect(screen.getByTestId('top-tasks-loading')).toBeInTheDocument(); + expect(screen.queryByTestId('top-tasks-empty')).not.toBeInTheDocument(); + }); + + it('renders one row per task with title, agent, tokens, and cost', () => { + render( + + ); + const table = screen.getByTestId('top-tasks-table'); + expect(table).toBeInTheDocument(); + expect(screen.getByText('Foo')).toBeInTheDocument(); + expect(screen.getByText('Bar')).toBeInTheDocument(); + // Both agent IDs render + expect(screen.getAllByText('react-agent').length).toBeGreaterThanOrEqual(2); + }); + + it('formats cost with at least four decimal places of precision', () => { + render(); + // Allow either $0.0123 or $0.012300 — anything but $0.01 (2dp) is fine + const cells = screen.getAllByText(/\$0\.0123/); + expect(cells.length).toBeGreaterThanOrEqual(1); + }); + + it('links the task title to the tasks page filtered by id', () => { + render(); + const link = screen.getByRole('link', { name: /build login flow/i }); + expect(link).toHaveAttribute('href', '/tasks?selected=abc-123'); + }); +}); diff --git a/web-ui/src/app/costs/page.tsx b/web-ui/src/app/costs/page.tsx index 0d969a36..7082d549 100644 --- a/web-ui/src/app/costs/page.tsx +++ b/web-ui/src/app/costs/page.tsx @@ -9,13 +9,20 @@ import { } from '@hugeicons/react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { SpendBarChart } from '@/components/costs/SpendBarChart'; +import { TopTasksTable } from '@/components/costs/TopTasksTable'; +import { AgentCostBars } from '@/components/costs/AgentCostBars'; import { WorkspaceSelector } from '@/components/workspace/WorkspaceSelector'; import { costsApi, workspaceApi } from '@/lib/api'; import { getSelectedWorkspacePath, setSelectedWorkspacePath, } from '@/lib/workspace-storage'; -import type { CostSummaryResponse, ApiError } from '@/types'; +import type { + CostSummaryResponse, + TaskCostsResponse, + AgentCostsResponse, + ApiError, +} from '@/types'; const DAY_OPTIONS = [ { value: 7, label: 'Last 7 days' }, @@ -48,6 +55,18 @@ export default function CostsPage() { { refreshInterval: 60000 } ); + const { data: tasksData, isLoading: tasksLoading } = useSWR( + workspacePath ? ['/api/v2/costs/tasks', workspacePath, days] : null, + () => costsApi.getTopTasks(workspacePath!, days), + { refreshInterval: 60000 } + ); + + const { data: agentsData, isLoading: agentsLoading } = useSWR( + workspacePath ? ['/api/v2/costs/by-agent', workspacePath, days] : null, + () => costsApi.getByAgent(workspacePath!, days), + { refreshInterval: 60000 } + ); + const handleSelectWorkspace = async (path: string) => { setIsSelecting(true); setSelectionError(null); @@ -175,6 +194,42 @@ export default function CostsPage() {
+ +
+
+

+ Top tasks by cost +

+

+ Top 10 over the selected window +

+
+ +
+ +
+
+

+ Cost by agent +

+

+ Spend grouped by agent over the selected window +

+
+ +
) : null}
diff --git a/web-ui/src/components/costs/AgentCostBars.tsx b/web-ui/src/components/costs/AgentCostBars.tsx new file mode 100644 index 00000000..9db69e71 --- /dev/null +++ b/web-ui/src/components/costs/AgentCostBars.tsx @@ -0,0 +1,108 @@ +'use client'; + +import type { AgentCostsResponse } from '@/types'; + +interface AgentCostBarsProps { + data: AgentCostsResponse; + isLoading?: boolean; +} + +function formatNumber(n: number): string { + return n.toLocaleString('en-US'); +} + +function formatCost(value: number): string { + return value.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 4, + maximumFractionDigits: 6, + }); +} + +export function AgentCostBars({ data, isLoading }: AgentCostBarsProps) { + if (isLoading) { + return ( +
+ ); + } + + const agents = data.by_agent; + if (agents.length === 0) { + return ( +
+ No per-agent cost data yet. +
+ ); + } + + // Find the max cost to scale bar widths. Guard against zero so the first + // bar still shows a visible track. + const maxCost = Math.max(...agents.map((a) => a.total_cost_usd), 0); + const totalTokens = data.total_input_tokens + data.total_output_tokens; + const inputPct = totalTokens > 0 + ? Math.round((data.total_input_tokens / totalTokens) * 100) + : 0; + + return ( +
+
    + {agents.map((agent) => { + const widthPct = maxCost > 0 + ? Math.max(2, Math.round((agent.total_cost_usd / maxCost) * 100)) + : 2; + return ( +
  • + + {agent.agent_id} + +
    +
    +
    + + {formatCost(agent.total_cost_usd)} + +
  • + ); + })} +
+ +
+
+ Input tokens:{' '} + + {formatNumber(data.total_input_tokens)} + +
+
+ Output tokens:{' '} + + {formatNumber(data.total_output_tokens)} + +
+
+ Input share:{' '} + {inputPct}% +
+
+
+ ); +} diff --git a/web-ui/src/components/costs/TopTasksTable.tsx b/web-ui/src/components/costs/TopTasksTable.tsx new file mode 100644 index 00000000..2545eff4 --- /dev/null +++ b/web-ui/src/components/costs/TopTasksTable.tsx @@ -0,0 +1,90 @@ +'use client'; + +import Link from 'next/link'; +import type { TaskCostEntry } from '@/types'; + +interface TopTasksTableProps { + tasks: TaskCostEntry[]; + isLoading?: boolean; +} + +function formatNumber(n: number): string { + return n.toLocaleString('en-US'); +} + +function formatCost(value: number): string { + return value.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 4, + maximumFractionDigits: 6, + }); +} + +export function TopTasksTable({ tasks, isLoading }: TopTasksTableProps) { + if (isLoading) { + return ( +
+ ); + } + + if (tasks.length === 0) { + return ( +
+ No per-task cost data yet. Run a task to start tracking spend. +
+ ); + } + + return ( +
+ + + + + + + + + + + + {tasks.map((task) => ( + + + + + + + + ))} + +
TaskAgentInputOutputCost
+ + {task.task_title} + + + {task.agent_id} + + {formatNumber(task.input_tokens)} + + {formatNumber(task.output_tokens)} + + {formatCost(task.total_cost_usd)} +
+
+ ); +} diff --git a/web-ui/src/components/tasks/TaskBoardContent.tsx b/web-ui/src/components/tasks/TaskBoardContent.tsx index 0ea412e0..fb2ff16f 100644 --- a/web-ui/src/components/tasks/TaskBoardContent.tsx +++ b/web-ui/src/components/tasks/TaskBoardContent.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { TaskColumn } from './TaskColumn'; -import type { Task, TaskStatus, ProofRequirement } from '@/types'; +import type { Task, TaskStatus, ProofRequirement, TaskCostEntry } from '@/types'; /** Column display order matches the task lifecycle. */ const COLUMN_ORDER: TaskStatus[] = [ @@ -28,6 +28,7 @@ interface TaskBoardContentProps { onDeselectAll?: (taskIds: string[]) => void; loadingTaskIds?: Set; requirementsMap?: Map; + costMap?: Map; } export function TaskBoardContent({ @@ -44,6 +45,7 @@ export function TaskBoardContent({ onDeselectAll, loadingTaskIds, requirementsMap, + costMap, }: TaskBoardContentProps) { /** Group flat task array into per-status buckets. */ const tasksByStatus = useMemo(() => { @@ -78,6 +80,7 @@ export function TaskBoardContent({ onDeselectAll={onDeselectAll} loadingTaskIds={loadingTaskIds} requirementsMap={requirementsMap} + costMap={costMap} /> ))}
diff --git a/web-ui/src/components/tasks/TaskBoardView.tsx b/web-ui/src/components/tasks/TaskBoardView.tsx index 840f3a1b..6a960bd7 100644 --- a/web-ui/src/components/tasks/TaskBoardView.tsx +++ b/web-ui/src/components/tasks/TaskBoardView.tsx @@ -11,11 +11,13 @@ import { BatchActionsBar } from './BatchActionsBar'; import { BulkActionConfirmDialog, type BulkActionType } from './BulkActionConfirmDialog'; import { Cancel01Icon, Task01Icon } from '@hugeicons/react'; import { Button } from '@/components/ui/button'; -import { tasksApi, prdApi } from '@/lib/api'; +import { tasksApi, prdApi, costsApi } from '@/lib/api'; import { useRequirementsLookup } from '@/hooks/useRequirementsLookup'; import type { TaskStatus, TaskListResponse, + TaskCostsResponse, + TaskCostEntry, BatchStrategy, ApiError, PrdListResponse, @@ -35,6 +37,26 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { ); const { requirementsMap } = useRequirementsLookup(workspacePath); + // Cost badge data (issue #558) — non-blocking. If this request fails or + // returns no data the board still renders; badges simply don't show. + // + // Limit 1000: we want a badge for every task on the board, not just the + // top 10 analytics view. The endpoint caps server-side at 1000. The SWR + // key is intentionally separate from the /costs page (which uses a + // user-controlled time range) — these are independent views. + const { data: costData } = useSWR( + `/api/v2/costs/tasks?path=${workspacePath}&limit=1000`, + () => costsApi.getTopTasks(workspacePath, 30, 1000), + { refreshInterval: 60000 } + ); + const costMap = useMemo(() => { + const map = new Map(); + for (const entry of costData?.tasks ?? []) { + map.set(entry.task_id, entry); + } + return map; + }, [costData?.tasks]); + // PRD existence check — drives empty state context message const { data: prdData } = useSWR( `/api/v2/prd?path=${workspacePath}`, @@ -416,6 +438,7 @@ export function TaskBoardView({ workspacePath }: TaskBoardViewProps) { onDeselectAll={handleDeselectAll} loadingTaskIds={loadingTaskIds} requirementsMap={requirementsMap} + costMap={costMap} />} {/* Task detail modal */} diff --git a/web-ui/src/components/tasks/TaskCard.tsx b/web-ui/src/components/tasks/TaskCard.tsx index 01d72791..55a59c20 100644 --- a/web-ui/src/components/tasks/TaskCard.tsx +++ b/web-ui/src/components/tasks/TaskCard.tsx @@ -1,14 +1,44 @@ 'use client'; import Link from 'next/link'; -import { PlayCircleIcon, CheckmarkCircle01Icon, LinkCircleIcon, Cancel01Icon, ArrowTurnBackwardIcon, Loading03Icon, BookOpen01Icon } from '@hugeicons/react'; +import { PlayCircleIcon, CheckmarkCircle01Icon, LinkCircleIcon, Cancel01Icon, ArrowTurnBackwardIcon, Loading03Icon, BookOpen01Icon, MoneyBag02Icon } from '@hugeicons/react'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'; import { STATUS_INFO } from '@/lib/taskStatusInfo'; -import type { Task, TaskStatus, ProofRequirement } from '@/types'; +import type { Task, TaskStatus, ProofRequirement, TaskCostEntry } from '@/types'; + +/** Format cost for the inline badge. + * + * AI per-task costs commonly sit below $0.01, so 2dp would display "$0.00" + * and hide real spend. Mirrors TopTasksTable's 4dp precision under $1 and + * falls back to 2dp once costs cross a dollar. + */ +function formatBadgeCost(value: number): string { + if (value < 0.01) { + return value.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 4, + maximumFractionDigits: 4, + }); + } + if (value < 1) { + return `$${value.toFixed(2)}`; + } + return value.toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + +function formatTokens(n: number): string { + return n.toLocaleString('en-US'); +} /** Map backend TaskStatus to badge variant name. */ const STATUS_BADGE_VARIANT: Record = { @@ -47,6 +77,8 @@ interface TaskCardProps { isLoading?: boolean; /** Map of requirement ID → ProofRequirement for badge lookup (shared SWR cache from parent). */ requirementsMap?: Map; + /** Map of task ID → cost entry. When present and entry has nonzero cost, a cost badge renders. */ + costMap?: Map; } export function TaskCard({ @@ -61,10 +93,13 @@ export function TaskCard({ onReset, isLoading = false, requirementsMap, + costMap, }: TaskCardProps) { const reqIds = task.requirement_ids ?? []; const firstReq = reqIds.length > 0 ? requirementsMap?.get(reqIds[0]) : undefined; const overflowCount = reqIds.length > 1 ? reqIds.length - 1 : 0; + const costEntry = costMap?.get(task.id); + const showCostBadge = costEntry !== undefined && costEntry.total_cost_usd > 0; return ( )} + {/* Cost badge (issue #558) */} + {showCostBadge && costEntry && ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + + + + {formatBadgeCost(costEntry.total_cost_usd)} + + + +

Input tokens: {formatTokens(costEntry.input_tokens)}

+

Output tokens: {formatTokens(costEntry.output_tokens)}

+

+ Total: {formatBadgeCost(costEntry.total_cost_usd)} +

+
+
+
+ )} + {/* Action buttons */} {(task.status === 'READY' || task.status === 'BACKLOG' || task.status === 'IN_PROGRESS' || task.status === 'FAILED') && (
diff --git a/web-ui/src/components/tasks/TaskColumn.tsx b/web-ui/src/components/tasks/TaskColumn.tsx index f9a307e3..a8df8400 100644 --- a/web-ui/src/components/tasks/TaskColumn.tsx +++ b/web-ui/src/components/tasks/TaskColumn.tsx @@ -3,7 +3,7 @@ import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { TaskCard } from './TaskCard'; -import type { Task, TaskStatus, ProofRequirement } from '@/types'; +import type { Task, TaskStatus, ProofRequirement, TaskCostEntry } from '@/types'; /** Human-readable column headers. */ const STATUS_LABEL: Record = { @@ -31,6 +31,7 @@ interface TaskColumnProps { onDeselectAll?: (taskIds: string[]) => void; loadingTaskIds?: Set; requirementsMap?: Map; + costMap?: Map; } export function TaskColumn({ @@ -48,6 +49,7 @@ export function TaskColumn({ onDeselectAll, loadingTaskIds = new Set(), requirementsMap, + costMap, }: TaskColumnProps) { const taskIds = tasks.map((t) => t.id); const selectedCount = tasks.filter((t) => selectedTaskIds.has(t.id)).length; @@ -102,6 +104,7 @@ export function TaskColumn({ onReset={onReset} isLoading={loadingTaskIds.has(task.id)} requirementsMap={requirementsMap} + costMap={costMap} /> )) )} diff --git a/web-ui/src/lib/api.ts b/web-ui/src/lib/api.ts index 7b496a88..50463fe8 100644 --- a/web-ui/src/lib/api.ts +++ b/web-ui/src/lib/api.ts @@ -68,6 +68,8 @@ import type { WorkspaceConfigResponse, UpdateWorkspaceConfigRequest, CostSummaryResponse, + TaskCostsResponse, + AgentCostsResponse, } from '@/types'; // FastAPI validation error format @@ -910,7 +912,7 @@ export const workspaceConfigApi = { }, }; -// Cost analytics API (issue #557) +// Cost analytics API (issues #557, #558) export const costsApi = { /** * Get aggregated spend summary for the workspace. @@ -925,6 +927,39 @@ export const costsApi = { ); return response.data; }, + + /** + * Get tasks by total cost over a `days` window, descending. + * + * Default `limit=10` matches the analytics view. The task board passes a + * much higher limit so its cost badge map covers every task that ever had + * spend, not just the top 10. + */ + getTopTasks: async ( + workspacePath: string, + days: number = 30, + limit: number = 10 + ): Promise => { + const response = await api.get( + '/api/v2/costs/tasks', + { params: { workspace_path: workspacePath, days, limit } } + ); + return response.data; + }, + + /** + * Get per-agent cost breakdown plus overall token totals. + */ + getByAgent: async ( + workspacePath: string, + days: number = 30 + ): Promise => { + const response = await api.get( + '/api/v2/costs/by-agent', + { params: { workspace_path: workspacePath, days } } + ); + return response.data; + }, }; export default api; diff --git a/web-ui/src/types/index.ts b/web-ui/src/types/index.ts index c307bf75..3a3e3589 100644 --- a/web-ui/src/types/index.ts +++ b/web-ui/src/types/index.ts @@ -632,3 +632,31 @@ export interface CostSummaryResponse { avg_cost_per_task: number; daily: DailyCostPoint[]; } + +// Cost analytics breakdowns (issue #558) +export interface TaskCostEntry { + task_id: string; + task_title: string; + agent_id: string; + input_tokens: number; + output_tokens: number; + total_cost_usd: number; +} + +export interface TaskCostsResponse { + tasks: TaskCostEntry[]; +} + +export interface AgentCostEntry { + agent_id: string; + input_tokens: number; + output_tokens: number; + total_cost_usd: number; + call_count: number; +} + +export interface AgentCostsResponse { + by_agent: AgentCostEntry[]; + total_input_tokens: number; + total_output_tokens: number; +}