-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprogress.py
More file actions
239 lines (202 loc) · 7.98 KB
/
progress.py
File metadata and controls
239 lines (202 loc) · 7.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
"""
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")