overcode 0.1.0__py3-none-any.whl → 0.1.2__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.
overcode/web_server.py CHANGED
@@ -6,13 +6,32 @@ Uses Python stdlib http.server - no additional dependencies required.
6
6
  """
7
7
 
8
8
  import json
9
+ import os
9
10
  import sys
11
+ from datetime import datetime
10
12
  from http.server import HTTPServer, BaseHTTPRequestHandler
11
- from typing import Optional
13
+ from pathlib import Path
14
+ from typing import Optional, Tuple
12
15
  from urllib.parse import urlparse, parse_qs
13
16
 
14
- from .web_templates import get_dashboard_html
15
- from .web_api import get_status_data, get_timeline_data, get_health_data
17
+ from .settings import (
18
+ get_web_server_pid_path,
19
+ get_web_server_port_path,
20
+ ensure_session_dir,
21
+ )
22
+ from .pid_utils import is_process_running, stop_process
23
+ from .web_templates import get_dashboard_html, get_analytics_html
24
+ from .web_api import (
25
+ get_status_data,
26
+ get_timeline_data,
27
+ get_health_data,
28
+ # Analytics API functions
29
+ get_analytics_sessions,
30
+ get_analytics_timeline,
31
+ get_analytics_stats,
32
+ get_analytics_daily,
33
+ get_time_presets,
34
+ )
16
35
 
17
36
 
18
37
  class OvercodeHandler(BaseHTTPRequestHandler):
@@ -136,3 +155,336 @@ def run_server(
136
155
  except KeyboardInterrupt:
137
156
  print("\nShutting down...")
138
157
  server.shutdown()
158
+
159
+
160
+ # =============================================================================
161
+ # Web Server Management (for TUI toggle)
162
+ # =============================================================================
163
+
164
+
165
+ def _find_available_port(start_port: int = 8080, max_attempts: int = 10) -> int:
166
+ """Find an available port starting from start_port."""
167
+ import socket
168
+
169
+ for i in range(max_attempts):
170
+ port = start_port + i
171
+ try:
172
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
173
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
174
+ s.bind(("127.0.0.1", port))
175
+ return port
176
+ except OSError:
177
+ continue
178
+ raise RuntimeError(f"Could not find available port in range {start_port}-{start_port + max_attempts}")
179
+
180
+
181
+ def is_web_server_running(session: str) -> bool:
182
+ """Check if the web server is running for the given session."""
183
+ pid_path = get_web_server_pid_path(session)
184
+ return is_process_running(pid_path)
185
+
186
+
187
+ def get_web_server_url(session: str) -> Optional[str]:
188
+ """Get the URL of the running web server for the session."""
189
+ if not is_web_server_running(session):
190
+ return None
191
+
192
+ port_path = get_web_server_port_path(session)
193
+ if not port_path.exists():
194
+ return None
195
+
196
+ try:
197
+ port = int(port_path.read_text().strip())
198
+ return f"http://localhost:{port}"
199
+ except (ValueError, OSError):
200
+ return None
201
+
202
+
203
+ def _log_to_file(session: str, message: str) -> None:
204
+ """Write a debug message to the web server log."""
205
+ from datetime import datetime
206
+ from .settings import get_session_dir
207
+ try:
208
+ log_path = get_session_dir(session) / "web_server.log"
209
+ log_path.parent.mkdir(parents=True, exist_ok=True)
210
+ with open(log_path, "a") as f:
211
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
212
+ f.write(f"[{timestamp}] [start_web_server] {message}\n")
213
+ except Exception:
214
+ pass
215
+
216
+
217
+ def start_web_server(session: str, port: int = 8080) -> Tuple[bool, str]:
218
+ """Start the analytics web server for a session.
219
+
220
+ Args:
221
+ session: tmux session name
222
+ port: Preferred port (will try alternatives if busy)
223
+
224
+ Returns:
225
+ Tuple of (success, message)
226
+ """
227
+ _log_to_file(session, f"start_web_server called with port={port}")
228
+
229
+ if is_web_server_running(session):
230
+ url = get_web_server_url(session)
231
+ _log_to_file(session, f"Already running at {url}")
232
+ return False, f"Already running at {url}"
233
+
234
+ ensure_session_dir(session)
235
+
236
+ # Find an available port
237
+ try:
238
+ actual_port = _find_available_port(port)
239
+ _log_to_file(session, f"Found available port: {actual_port}")
240
+ except RuntimeError as e:
241
+ _log_to_file(session, f"Failed to find port: {e}")
242
+ return False, str(e)
243
+
244
+ # Start the server as a subprocess (works better with Textual TUI)
245
+ import subprocess
246
+ from .settings import get_session_dir
247
+ log_path = get_session_dir(session) / "web_server.log"
248
+
249
+ try:
250
+ # Open log file in append mode for stderr
251
+ log_file = open(log_path, "a")
252
+ cmd = [sys.executable, "-m", "overcode.web_server_runner",
253
+ "--session", session, "--port", str(actual_port)]
254
+ _log_to_file(session, f"Starting subprocess: {' '.join(cmd)}")
255
+ proc = subprocess.Popen(
256
+ cmd,
257
+ stdout=subprocess.DEVNULL,
258
+ stderr=log_file,
259
+ start_new_session=True,
260
+ )
261
+ _log_to_file(session, f"Subprocess started with PID: {proc.pid}")
262
+ except (OSError, subprocess.SubprocessError) as e:
263
+ _log_to_file(session, f"Subprocess failed: {e}")
264
+ return False, f"Failed to start: {e}"
265
+
266
+ # Wait briefly for the server to start
267
+ import time
268
+ for i in range(10):
269
+ time.sleep(0.1)
270
+ if is_web_server_running(session):
271
+ url = get_web_server_url(session)
272
+ _log_to_file(session, f"Server started successfully at {url}")
273
+ return True, f"Started at {url}"
274
+ _log_to_file(session, f"Waiting for server... attempt {i+1}/10")
275
+
276
+ _log_to_file(session, "Server failed to start within timeout")
277
+ return False, "Failed to start web server"
278
+
279
+
280
+ def stop_web_server(session: str) -> Tuple[bool, str]:
281
+ """Stop the analytics web server for a session.
282
+
283
+ Args:
284
+ session: tmux session name
285
+
286
+ Returns:
287
+ Tuple of (success, message)
288
+ """
289
+ pid_path = get_web_server_pid_path(session)
290
+ port_path = get_web_server_port_path(session)
291
+
292
+ if not is_process_running(pid_path):
293
+ # Clean up stale files
294
+ try:
295
+ pid_path.unlink(missing_ok=True)
296
+ port_path.unlink(missing_ok=True)
297
+ except Exception:
298
+ pass
299
+ return False, "Not running"
300
+
301
+ stopped = stop_process(pid_path)
302
+
303
+ # Clean up port file
304
+ try:
305
+ port_path.unlink(missing_ok=True)
306
+ except Exception:
307
+ pass
308
+
309
+ if stopped:
310
+ return True, "Stopped"
311
+ else:
312
+ return False, "Failed to stop"
313
+
314
+
315
+ def toggle_web_server(session: str, port: int = 8080) -> Tuple[bool, str]:
316
+ """Toggle the web server on/off for a session.
317
+
318
+ Args:
319
+ session: tmux session name
320
+ port: Preferred port (used when starting)
321
+
322
+ Returns:
323
+ Tuple of (is_now_running, message)
324
+ """
325
+ if is_web_server_running(session):
326
+ success, msg = stop_web_server(session)
327
+ return False, msg
328
+ else:
329
+ success, msg = start_web_server(session, port)
330
+ return success, msg
331
+
332
+
333
+ # =============================================================================
334
+ # Analytics Web Server (for `overcode web` historical analytics dashboard)
335
+ # =============================================================================
336
+
337
+
338
+ class AnalyticsHandler(BaseHTTPRequestHandler):
339
+ """HTTP request handler for analytics dashboard."""
340
+
341
+ # Set by run_analytics_server before starting
342
+ tmux_session: str = "agents"
343
+
344
+ def do_GET(self) -> None:
345
+ """Handle GET requests."""
346
+ parsed = urlparse(self.path)
347
+ path = parsed.path
348
+ query = parse_qs(parsed.query)
349
+
350
+ # Parse time range from query params
351
+ start = self._parse_datetime(query.get("start", [None])[0])
352
+ end = self._parse_datetime(query.get("end", [None])[0])
353
+
354
+ if path == "/" or path == "/index.html":
355
+ self._serve_analytics_dashboard()
356
+ elif path == "/static/chart.min.js":
357
+ self._serve_chartjs()
358
+ elif path == "/api/analytics/sessions":
359
+ self._serve_json(get_analytics_sessions(start, end))
360
+ elif path == "/api/analytics/timeline":
361
+ self._serve_json(get_analytics_timeline(self.tmux_session, start, end))
362
+ elif path == "/api/analytics/stats":
363
+ self._serve_json(get_analytics_stats(self.tmux_session, start, end))
364
+ elif path == "/api/analytics/daily":
365
+ self._serve_json(get_analytics_daily(start, end))
366
+ elif path == "/api/analytics/presets":
367
+ self._serve_json(get_time_presets())
368
+ elif path == "/health":
369
+ self._serve_json(get_health_data())
370
+ else:
371
+ self.send_error(404, "Not Found")
372
+
373
+ def _parse_datetime(self, value: Optional[str]) -> Optional[datetime]:
374
+ """Parse ISO datetime string from query param."""
375
+ if not value:
376
+ return None
377
+ try:
378
+ return datetime.fromisoformat(value)
379
+ except (ValueError, TypeError):
380
+ return None
381
+
382
+ def _serve_analytics_dashboard(self) -> None:
383
+ """Serve the analytics dashboard HTML page."""
384
+ try:
385
+ html = get_analytics_html()
386
+ html_bytes = html.encode("utf-8")
387
+ self.send_response(200)
388
+ self.send_header("Content-Type", "text/html; charset=utf-8")
389
+ self.send_header("Content-Length", str(len(html_bytes)))
390
+ self.send_header("Cache-Control", "no-cache")
391
+ self.end_headers()
392
+ self.wfile.write(html_bytes)
393
+ except Exception as e:
394
+ self.send_error(500, f"Internal error: {e}")
395
+
396
+ def _serve_chartjs(self) -> None:
397
+ """Serve the embedded Chart.js library."""
398
+ try:
399
+ from .web_chartjs import CHARTJS_JS
400
+ js_bytes = CHARTJS_JS.encode("utf-8")
401
+ self.send_response(200)
402
+ self.send_header("Content-Type", "application/javascript")
403
+ self.send_header("Content-Length", str(len(js_bytes)))
404
+ # Cache for 1 year - it's a versioned static asset
405
+ self.send_header("Cache-Control", "public, max-age=31536000")
406
+ self.end_headers()
407
+ self.wfile.write(js_bytes)
408
+ except Exception as e:
409
+ self.send_error(500, f"Internal error: {e}")
410
+
411
+ def _serve_json(self, data) -> None:
412
+ """Serve JSON data."""
413
+ try:
414
+ body = json.dumps(data, indent=2, default=str)
415
+ body_bytes = body.encode("utf-8")
416
+ self.send_response(200)
417
+ self.send_header("Content-Type", "application/json")
418
+ self.send_header("Content-Length", str(len(body_bytes)))
419
+ self.send_header("Access-Control-Allow-Origin", "*")
420
+ self.send_header("Cache-Control", "no-cache")
421
+ self.end_headers()
422
+ self.wfile.write(body_bytes)
423
+ except Exception as e:
424
+ self.send_error(500, f"Internal error: {e}")
425
+
426
+ def log_message(self, format: str, *args) -> None:
427
+ """Custom log format - less verbose than default."""
428
+ if args and len(args) >= 2:
429
+ status = str(args[1])
430
+ path = str(args[0])
431
+ # Don't log successful API polls
432
+ if status.startswith("2") and "/api/" in path:
433
+ return
434
+ sys.stderr.write(f"[analytics] {args[0] if args else format}\n")
435
+
436
+
437
+ def run_analytics_server(
438
+ host: str = "127.0.0.1",
439
+ port: int = 8080,
440
+ tmux_session: str = "agents",
441
+ ) -> None:
442
+ """Run the analytics web dashboard server.
443
+
444
+ Args:
445
+ host: Host to bind to (default: 127.0.0.1 for local only)
446
+ port: Port to listen on (default: 8080)
447
+ tmux_session: tmux session name for session-specific data
448
+ """
449
+ # Set the tmux session on the handler class
450
+ AnalyticsHandler.tmux_session = tmux_session
451
+
452
+ server_address = (host, port)
453
+
454
+ try:
455
+ server = HTTPServer(server_address, AnalyticsHandler)
456
+ except OSError as e:
457
+ if "Address already in use" in str(e):
458
+ print(f"Error: Port {port} is already in use. Try a different port with --port")
459
+ sys.exit(1)
460
+ raise
461
+
462
+ # Get actual bound address for display
463
+ bound_host, bound_port = server.server_address
464
+
465
+ print(f"Overcode Analytics Dashboard")
466
+ print(f"=============================")
467
+ print(f"")
468
+ print(f"Local: http://localhost:{bound_port}")
469
+
470
+ if host == "0.0.0.0":
471
+ # Try to get the machine's IP for network access
472
+ try:
473
+ import socket
474
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
475
+ s.connect(("8.8.8.8", 80))
476
+ ip = s.getsockname()[0]
477
+ s.close()
478
+ print(f"Network: http://{ip}:{bound_port}")
479
+ except Exception:
480
+ print(f"Network: http://<your-ip>:{bound_port}")
481
+
482
+ print(f"")
483
+ print(f"Press Ctrl+C to stop")
484
+ print(f"")
485
+
486
+ try:
487
+ server.serve_forever()
488
+ except KeyboardInterrupt:
489
+ print("\nShutting down...")
490
+ server.shutdown()
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Web server runner - standalone script for running the analytics server.
4
+
5
+ This is invoked as a subprocess from the TUI to avoid multiprocessing issues
6
+ with Textual's file descriptor management.
7
+ """
8
+
9
+ import argparse
10
+ import os
11
+ import signal
12
+ import sys
13
+ import traceback
14
+ from datetime import datetime
15
+ from http.server import HTTPServer
16
+ from pathlib import Path
17
+
18
+
19
+ def get_log_path(session: str) -> Path:
20
+ """Get path for web server log file."""
21
+ from .settings import get_session_dir
22
+ return get_session_dir(session) / "web_server.log"
23
+
24
+
25
+ def log(session: str, message: str) -> None:
26
+ """Write a log message to the web server log file."""
27
+ try:
28
+ log_path = get_log_path(session)
29
+ log_path.parent.mkdir(parents=True, exist_ok=True)
30
+ with open(log_path, "a") as f:
31
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
32
+ f.write(f"[{timestamp}] {message}\n")
33
+ except Exception:
34
+ pass # Can't log, silently fail
35
+
36
+
37
+ def main():
38
+ parser = argparse.ArgumentParser(description="Run Overcode analytics web server")
39
+ parser.add_argument("--session", "-s", required=True, help="Session name")
40
+ parser.add_argument("--port", "-p", type=int, default=8080, help="Port to listen on")
41
+ args = parser.parse_args()
42
+
43
+ session = args.session
44
+ port = args.port
45
+
46
+ log(session, f"Starting web server on port {port}")
47
+
48
+ try:
49
+ from .settings import get_web_server_pid_path, get_web_server_port_path
50
+ from .pid_utils import write_pid_file
51
+
52
+ pid_path = get_web_server_pid_path(session)
53
+ port_path = get_web_server_port_path(session)
54
+
55
+ # Write PID file
56
+ write_pid_file(pid_path)
57
+ log(session, f"Wrote PID file: {pid_path}")
58
+
59
+ # Write port file so TUI can find the URL
60
+ port_path.write_text(str(port))
61
+ log(session, f"Wrote port file: {port_path}")
62
+
63
+ def cleanup(signum, frame):
64
+ log(session, f"Received signal {signum}, shutting down")
65
+ try:
66
+ pid_path.unlink(missing_ok=True)
67
+ port_path.unlink(missing_ok=True)
68
+ except Exception:
69
+ pass
70
+ sys.exit(0)
71
+
72
+ signal.signal(signal.SIGTERM, cleanup)
73
+ signal.signal(signal.SIGINT, cleanup)
74
+
75
+ # Import here to avoid circular imports
76
+ from .web_server import AnalyticsHandler
77
+
78
+ server_address = ("127.0.0.1", port)
79
+ log(session, f"Creating HTTP server at {server_address}")
80
+ server = HTTPServer(server_address, AnalyticsHandler)
81
+ log(session, "Server created, starting serve_forever()")
82
+
83
+ # Redirect stdout/stderr AFTER setup is complete
84
+ sys.stdout = open(os.devnull, 'w')
85
+ sys.stderr = open(os.devnull, 'w')
86
+
87
+ server.serve_forever()
88
+
89
+ except Exception as e:
90
+ log(session, f"ERROR: {e}\n{traceback.format_exc()}")
91
+ # Clean up on error
92
+ try:
93
+ from .settings import get_web_server_pid_path, get_web_server_port_path
94
+ pid_path = get_web_server_pid_path(session)
95
+ port_path = get_web_server_port_path(session)
96
+ pid_path.unlink(missing_ok=True)
97
+ port_path.unlink(missing_ok=True)
98
+ except Exception:
99
+ pass
100
+ sys.exit(1)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()