Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion codeframe/core/react_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,9 @@ def _persist_token_usage(self, task_id: str) -> None:
call_type=record["call_type"],
)
except Exception:
logger.debug(
# Log at WARNING (issue #712): silent debug-level swallowing hid the
# missing token_usage table and dropped all cost data unnoticed.
logger.warning(
"Token usage persistence failed for task %s", task_id, exc_info=True,
)
finally:
Expand Down
38 changes: 38 additions & 0 deletions codeframe/core/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,35 @@ def _open_db(db_path: str | Path) -> sqlite3.Connection:
return conn


def _create_token_usage_schema(cursor: sqlite3.Cursor) -> None:
"""Create the per-workspace `token_usage` table + indexes (issue #712).

Shared by initial creation and the upgrade path so the two never drift.
Columns match the repository INSERT (token_repository.save_token_usage);
task_id/agent_id/project_id are TEXT because v2 task IDs are UUID strings.
Columns are intentionally nullable: the INSERT omits some (e.g.
actual_cost_usd) — do not add NOT NULL constraints back.
"""
cursor.execute("""
CREATE TABLE IF NOT EXISTS token_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT,
agent_id TEXT,
project_id TEXT,
model_name TEXT,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
estimated_cost_usd REAL DEFAULT 0,
actual_cost_usd REAL,
call_type TEXT,
timestamp TEXT
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_token_usage_timestamp ON token_usage(timestamp)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_token_usage_task_id ON token_usage(task_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_token_usage_agent_id ON token_usage(agent_id)")


def _init_database(db_path: Path) -> None:
"""Initialize the workspace SQLite database with v2 schema.

Expand Down Expand Up @@ -392,6 +421,10 @@ def _init_database(db_path: Path) -> None:
)
""")

# Per-workspace token/cost tracking (issue #712 — was never created here,
# so every save_token_usage() raised "no such table" and cost data dropped).
_create_token_usage_schema(cursor)

# Create indexes for common queries
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)")
Expand Down Expand Up @@ -436,6 +469,11 @@ def _ensure_schema_upgrades(db_path: Path) -> None:
conn = _open_db(db_path)
cursor = conn.cursor()

# token_usage was added after the initial schema (issue #712); create it for
# existing workspaces. CREATE TABLE IF NOT EXISTS keeps this idempotent.
_create_token_usage_schema(cursor)
conn.commit()

# Check if batch_runs table exists, if not create it
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='batch_runs'"
Expand Down
126 changes: 126 additions & 0 deletions tests/core/test_token_usage_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Regression tests for the per-workspace `token_usage` table (issue #712 / P0.1).

Before the fix, no production code created `token_usage` — the only CREATE TABLE
lived in test fixtures — so every `save_token_usage()` raised
`OperationalError: no such table` (silently swallowed in react_agent) and all
cost/token data was dropped. These tests lock in that:

1. `create_or_load_workspace()` creates `token_usage` with the columns the
repository INSERT expects and indexes on timestamp/task_id/agent_id.
2. A fresh workspace can save a record and read it back through the same
`Database` path react_agent uses in production.
3. `_ensure_schema_upgrades()` adds the table to a pre-existing DB that lacks it.
"""

import sqlite3
from datetime import datetime, timezone
from pathlib import Path

import pytest

from codeframe.core.models import CallType, TokenUsage
from codeframe.core.workspace import (
_ensure_schema_upgrades,
create_or_load_workspace,
)
from codeframe.platform_store.database import Database

pytestmark = pytest.mark.v2


@pytest.fixture
def temp_repo(tmp_path: Path) -> Path:
repo = tmp_path / "test-repo"
repo.mkdir()
return repo


def _table_columns(db_path: Path, table: str) -> set[str]:
conn = sqlite3.connect(str(db_path))
try:
return {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
finally:
conn.close()


def _index_names(db_path: Path, table: str) -> set[str]:
conn = sqlite3.connect(str(db_path))
try:
return {row[1] for row in conn.execute(f"PRAGMA index_list({table})")}
finally:
conn.close()


class TestTokenUsageSchema:
def test_fresh_workspace_creates_token_usage_table(self, temp_repo: Path):
ws = create_or_load_workspace(temp_repo)
cols = _table_columns(ws.db_path, "token_usage")
# Every column the repository INSERT writes must exist.
assert {
"id",
"task_id",
"agent_id",
"project_id",
"model_name",
"input_tokens",
"output_tokens",
"estimated_cost_usd",
"actual_cost_usd",
"call_type",
"timestamp",
} <= cols

def test_token_usage_has_expected_indexes(self, temp_repo: Path):
ws = create_or_load_workspace(temp_repo)
idx = _index_names(ws.db_path, "token_usage")
assert "idx_token_usage_timestamp" in idx
assert "idx_token_usage_task_id" in idx
assert "idx_token_usage_agent_id" in idx

def test_save_and_read_roundtrip(self, temp_repo: Path):
"""Mirrors react_agent's production path: create_or_load_workspace then
a Database over the same db_path saves and reads a record with no error."""
ws = create_or_load_workspace(temp_repo)
db = Database(str(ws.db_path))
db.initialize()
try:
usage = TokenUsage(
task_id="a1b2c3d4-uuid-task", # v2 UUID string, not int
agent_id="react-agent",
project_id=0,
model_name="claude-sonnet-4-5",
input_tokens=1000,
output_tokens=500,
estimated_cost_usd=0.0105,
call_type=CallType.TASK_EXECUTION,
timestamp=datetime.now(timezone.utc),
)
db.save_token_usage(usage)
rows = db.get_workspace_token_usage()
finally:
db.close()

assert len(rows) == 1
assert rows[0]["task_id"] == "a1b2c3d4-uuid-task"
assert rows[0]["input_tokens"] == 1000
assert rows[0]["output_tokens"] == 500

def test_ensure_schema_upgrades_adds_table_to_old_db(self, tmp_path: Path):
"""An existing DB created before token_usage existed gets the table on upgrade."""
db_path = tmp_path / "old.db"
conn = sqlite3.connect(str(db_path))
conn.execute("CREATE TABLE workspace (id INTEGER PRIMARY KEY)")
conn.commit()
conn.close()

# Precondition: table absent.
conn = sqlite3.connect(str(db_path))
present = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage'"
).fetchone()
conn.close()
assert present is None

_ensure_schema_upgrades(db_path)

assert "input_tokens" in _table_columns(db_path, "token_usage")
28 changes: 6 additions & 22 deletions tests/ui/test_costs_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,36 +17,20 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient

from codeframe.core.workspace import _create_token_usage_schema

pytestmark = pytest.mark.v2


def _ensure_token_usage_table(db_path: Path) -> None:
"""Create token_usage on the workspace DB without invoking SchemaManager.
"""Create token_usage on the workspace DB via the real schema builder (#712).

The router opens the workspace DB directly and tolerates the table
being absent. Tests that exercise real data need to create the table
inline to mirror what an agent run would produce.
Delegates to the production DDL so this fixture can never drift from the
columns the app actually reads/writes.
"""
conn = sqlite3.connect(str(db_path))
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS token_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER,
agent_id TEXT NOT NULL,
project_id INTEGER NOT NULL,
model_name TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
estimated_cost_usd REAL NOT NULL,
actual_cost_usd REAL,
call_type TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
session_id TEXT DEFAULT NULL
)
"""
)
_create_token_usage_schema(conn.cursor())
conn.commit()
finally:
conn.close()
Expand Down
Loading