mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-29 22:02:05 +00:00
240 lines
8.0 KiB
Python
240 lines
8.0 KiB
Python
"""
|
|
Progress Tracking Utilities
|
|
===========================
|
|
|
|
Functions for tracking and displaying progress of the autonomous coding agent.
|
|
Uses direct SQLite access for database queries.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
WEBHOOK_URL = os.environ.get("PROGRESS_N8N_WEBHOOK_URL")
|
|
PROGRESS_CACHE_FILE = ".progress_cache"
|
|
|
|
|
|
def has_features(project_dir: Path) -> bool:
|
|
"""
|
|
Check if the project has features in the database.
|
|
|
|
This is used to determine if the initializer agent needs to run.
|
|
We check the database directly (not via API) since the API server
|
|
may not be running yet when this check is performed.
|
|
|
|
Returns True if:
|
|
- features.db exists AND has at least 1 feature, OR
|
|
- feature_list.json exists (legacy format)
|
|
|
|
Returns False if no features exist (initializer needs to run).
|
|
"""
|
|
import sqlite3
|
|
|
|
# Check legacy JSON file first
|
|
json_file = project_dir / "feature_list.json"
|
|
if json_file.exists():
|
|
return True
|
|
|
|
# Check SQLite database
|
|
db_file = project_dir / "features.db"
|
|
if not db_file.exists():
|
|
return False
|
|
|
|
try:
|
|
conn = sqlite3.connect(db_file)
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT COUNT(*) FROM features")
|
|
count = cursor.fetchone()[0]
|
|
conn.close()
|
|
return count > 0
|
|
except Exception:
|
|
# Database exists but can't be read or has no features table
|
|
return False
|
|
|
|
|
|
def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
|
|
"""
|
|
Count passing, in_progress, and total tests via direct database access.
|
|
|
|
Args:
|
|
project_dir: Directory containing the project
|
|
|
|
Returns:
|
|
(passing_count, in_progress_count, total_count)
|
|
"""
|
|
db_file = project_dir / "features.db"
|
|
if not db_file.exists():
|
|
return 0, 0, 0
|
|
|
|
try:
|
|
conn = sqlite3.connect(db_file)
|
|
cursor = conn.cursor()
|
|
# Single aggregate query instead of 3 separate COUNT queries
|
|
# Handle case where in_progress column doesn't exist yet (legacy DBs)
|
|
try:
|
|
cursor.execute("""
|
|
SELECT
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing,
|
|
SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress
|
|
FROM features
|
|
""")
|
|
row = cursor.fetchone()
|
|
total = row[0] or 0
|
|
passing = row[1] or 0
|
|
in_progress = row[2] or 0
|
|
except sqlite3.OperationalError:
|
|
# Fallback for databases without in_progress column
|
|
cursor.execute("""
|
|
SELECT
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing
|
|
FROM features
|
|
""")
|
|
row = cursor.fetchone()
|
|
total = row[0] or 0
|
|
passing = row[1] or 0
|
|
in_progress = 0
|
|
conn.close()
|
|
return passing, in_progress, total
|
|
except Exception as e:
|
|
print(f"[Database error in count_passing_tests: {e}]")
|
|
return 0, 0, 0
|
|
|
|
|
|
def get_all_passing_features(project_dir: Path) -> list[dict]:
|
|
"""
|
|
Get all passing features for webhook notifications.
|
|
|
|
Args:
|
|
project_dir: Directory containing the project
|
|
|
|
Returns:
|
|
List of dicts with id, category, name for each passing feature
|
|
"""
|
|
db_file = project_dir / "features.db"
|
|
if not db_file.exists():
|
|
return []
|
|
|
|
try:
|
|
conn = sqlite3.connect(db_file)
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT id, category, name FROM features WHERE passes = 1 ORDER BY priority ASC"
|
|
)
|
|
features = [
|
|
{"id": row[0], "category": row[1], "name": row[2]}
|
|
for row in cursor.fetchall()
|
|
]
|
|
conn.close()
|
|
return features
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def send_progress_webhook(passing: int, total: int, project_dir: Path) -> None:
|
|
"""Send webhook notification when progress increases."""
|
|
if not WEBHOOK_URL:
|
|
return # Webhook not configured
|
|
|
|
cache_file = project_dir / PROGRESS_CACHE_FILE
|
|
previous = 0
|
|
previous_passing_ids = set()
|
|
|
|
# Read previous progress and passing feature IDs
|
|
if cache_file.exists():
|
|
try:
|
|
cache_data = json.loads(cache_file.read_text())
|
|
previous = cache_data.get("count", 0)
|
|
previous_passing_ids = set(cache_data.get("passing_ids", []))
|
|
except Exception:
|
|
previous = 0
|
|
|
|
# Only notify if progress increased
|
|
if passing > previous:
|
|
# Find which features are now passing via API
|
|
completed_tests = []
|
|
current_passing_ids = []
|
|
|
|
# Detect transition from old cache format (had count but no passing_ids)
|
|
# In this case, we can't reliably identify which specific tests are new
|
|
is_old_cache_format = len(previous_passing_ids) == 0 and previous > 0
|
|
|
|
# Get all passing features via direct database access
|
|
all_passing = get_all_passing_features(project_dir)
|
|
for feature in all_passing:
|
|
feature_id = feature.get("id")
|
|
current_passing_ids.append(feature_id)
|
|
# Only identify individual new tests if we have previous IDs to compare
|
|
if not is_old_cache_format and feature_id not in previous_passing_ids:
|
|
# This feature is newly passing
|
|
name = feature.get("name", f"Feature #{feature_id}")
|
|
category = feature.get("category", "")
|
|
if category:
|
|
completed_tests.append(f"{category} {name}")
|
|
else:
|
|
completed_tests.append(name)
|
|
|
|
payload = {
|
|
"event": "test_progress",
|
|
"passing": passing,
|
|
"total": total,
|
|
"percentage": round((passing / total) * 100, 1) if total > 0 else 0,
|
|
"previous_passing": previous,
|
|
"tests_completed_this_session": passing - previous,
|
|
"completed_tests": completed_tests,
|
|
"project": project_dir.name,
|
|
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
}
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
WEBHOOK_URL,
|
|
data=json.dumps([payload]).encode("utf-8"), # n8n expects array
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
urllib.request.urlopen(req, timeout=5)
|
|
except Exception as e:
|
|
print(f"[Webhook notification failed: {e}]")
|
|
|
|
# Update cache with count and passing IDs
|
|
cache_file.write_text(
|
|
json.dumps({"count": passing, "passing_ids": current_passing_ids})
|
|
)
|
|
else:
|
|
# Update cache even if no change (for initial state)
|
|
if not cache_file.exists():
|
|
all_passing = get_all_passing_features(project_dir)
|
|
current_passing_ids = [f.get("id") for f in all_passing]
|
|
cache_file.write_text(
|
|
json.dumps({"count": passing, "passing_ids": current_passing_ids})
|
|
)
|
|
|
|
|
|
def print_session_header(session_num: int, is_initializer: bool) -> None:
|
|
"""Print a formatted header for the session."""
|
|
session_type = "INITIALIZER" if is_initializer else "CODING AGENT"
|
|
|
|
print("\n" + "=" * 70)
|
|
print(f" SESSION {session_num}: {session_type}")
|
|
print("=" * 70)
|
|
print()
|
|
|
|
|
|
def print_progress_summary(project_dir: Path) -> None:
|
|
"""Print a summary of current progress."""
|
|
passing, in_progress, total = count_passing_tests(project_dir)
|
|
|
|
if total > 0:
|
|
percentage = (passing / total) * 100
|
|
status_parts = [f"{passing}/{total} tests passing ({percentage:.1f}%)"]
|
|
if in_progress > 0:
|
|
status_parts.append(f"{in_progress} in progress")
|
|
print(f"\nProgress: {', '.join(status_parts)}")
|
|
send_progress_webhook(passing, total, project_dir)
|
|
else:
|
|
print("\nProgress: No features in database yet")
|