Skip to content

Commit 0ff42c5

Browse files
author
Dylan Huang
committed
initial server file watching works
1 parent 1c00878 commit 0ff42c5

16 files changed

Lines changed: 3786 additions & 0 deletions

File tree

eval_protocol/cli.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
logger = logging.getLogger(__name__)
1616

17+
1718
from eval_protocol.evaluation import create_evaluation, preview_evaluation
1819

1920
from .cli_commands.agent_eval_cmd import agent_eval_command
@@ -26,6 +27,7 @@
2627
from .cli_commands.deploy_mcp import deploy_mcp_command
2728
from .cli_commands.preview import preview_command
2829
from .cli_commands.run_eval_cmd import hydra_cli_entry_point
30+
from .cli_commands.logs import logs_command
2931

3032

3133
def parse_args(args=None):
@@ -285,6 +287,39 @@ def parse_args(args=None):
285287
help="Override the number of parallel rollouts to execute for each task.",
286288
)
287289

290+
# Logs command
291+
logs_parser = subparsers.add_parser("logs", help="Serve logs with file watching and real-time updates")
292+
logs_parser.add_argument(
293+
"--build-dir",
294+
default="dist",
295+
help="Path to the Vite build output directory (default: dist)",
296+
)
297+
logs_parser.add_argument(
298+
"--host",
299+
default="localhost",
300+
help="Host to bind the server to (default: localhost)",
301+
)
302+
logs_parser.add_argument(
303+
"--port",
304+
type=int,
305+
default=4789,
306+
help="Port to bind the server to (default: 4789)",
307+
)
308+
logs_parser.add_argument(
309+
"--index-file",
310+
default="index.html",
311+
help="Name of the main index file (default: index.html)",
312+
)
313+
logs_parser.add_argument(
314+
"--watch-paths",
315+
help="Comma-separated list of paths to watch for file changes (default: current directory)",
316+
)
317+
logs_parser.add_argument(
318+
"--reload",
319+
action="store_true",
320+
help="Enable auto-reload (default: False)",
321+
)
322+
288323
# Run command (for Hydra-based evaluations)
289324
# This subparser intentionally defines no arguments itself.
290325
# All arguments after 'run' will be passed to Hydra by parse_known_args.
@@ -338,6 +373,8 @@ def main():
338373
return deploy_mcp_command(args)
339374
elif args.command == "agent-eval":
340375
return agent_eval_command(args)
376+
elif args.command == "logs":
377+
return logs_command(args)
341378
elif args.command == "run":
342379
# For the 'run' command, Hydra takes over argument parsing.
343380

eval_protocol/cli_commands/logs.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""
2+
CLI command for serving logs with file watching and real-time updates.
3+
"""
4+
5+
import sys
6+
from pathlib import Path
7+
8+
from ..utils.logs_server import serve_logs
9+
10+
11+
def logs_command(args):
12+
"""Serve logs with file watching and real-time updates"""
13+
14+
# Parse watch paths
15+
watch_paths = None
16+
if args.watch_paths:
17+
watch_paths = args.watch_paths.split(",")
18+
watch_paths = [path.strip() for path in watch_paths if path.strip()]
19+
20+
print(f"🚀 Starting Eval Protocol Logs Server")
21+
print(f"🌐 URL: http://{args.host}:{args.port}")
22+
print(f"🔌 WebSocket: ws://{args.host}:{args.port}/ws")
23+
print(f"👀 Watching paths: {watch_paths or ['current directory']}")
24+
print("Press Ctrl+C to stop the server")
25+
print("-" * 50)
26+
27+
try:
28+
serve_logs(
29+
host=args.host,
30+
port=args.port,
31+
watch_paths=watch_paths,
32+
reload=args.reload,
33+
)
34+
return 0
35+
except KeyboardInterrupt:
36+
print("\n🛑 Server stopped by user")
37+
return 0
38+
except Exception as e:
39+
print(f"❌ Error starting server: {e}")
40+
return 1

eval_protocol/utils/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,8 @@
66

77
# For now, allow direct import of modules like:
88
# from eval_protocol.utils.dataset_helpers import ...
9+
10+
# Export ViteServer for easier access
11+
from .logs_server import LogsServer
12+
13+
__all__ = ["LogsServer"]

eval_protocol/utils/logs_server.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import asyncio
2+
import json
3+
import logging
4+
import os
5+
import time
6+
from pathlib import Path
7+
from typing import Dict, List, Optional, Set
8+
from watchdog.events import FileSystemEventHandler, FileSystemEvent
9+
from watchdog.observers import Observer
10+
11+
import uvicorn
12+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
13+
from fastapi.staticfiles import StaticFiles
14+
from fastapi.responses import FileResponse
15+
16+
from .vite_server import ViteServer
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class FileWatcher(FileSystemEventHandler):
22+
"""File system watcher that tracks file changes."""
23+
24+
def __init__(self, websocket_manager):
25+
self.websocket_manager = websocket_manager
26+
self.ignored_patterns = {
27+
".git",
28+
"__pycache__",
29+
".pytest_cache",
30+
"node_modules",
31+
".DS_Store",
32+
"*.pyc",
33+
"*.pyo",
34+
"*.pyd",
35+
".coverage",
36+
"*.log",
37+
"*.tmp",
38+
"*.swp",
39+
"*.swo",
40+
"*~",
41+
}
42+
43+
def should_ignore(self, path: str) -> bool:
44+
"""Check if a path should be ignored."""
45+
path_lower = path.lower()
46+
for pattern in self.ignored_patterns:
47+
if pattern.startswith("*"):
48+
if path_lower.endswith(pattern[1:]):
49+
return True
50+
elif pattern in path_lower:
51+
return True
52+
return False
53+
54+
def on_created(self, event: FileSystemEvent):
55+
if not event.is_directory and not self.should_ignore(event.src_path):
56+
self.websocket_manager.broadcast_file_update("file_created", event.src_path)
57+
58+
def on_modified(self, event: FileSystemEvent):
59+
if not event.is_directory and not self.should_ignore(event.src_path):
60+
self.websocket_manager.broadcast_file_update("file_changed", event.src_path)
61+
62+
def on_deleted(self, event: FileSystemEvent):
63+
if not event.is_directory and not self.should_ignore(event.src_path):
64+
self.websocket_manager.broadcast_file_update("file_deleted", event.src_path)
65+
66+
67+
class WebSocketManager:
68+
"""Manages WebSocket connections and broadcasts messages."""
69+
70+
def __init__(self):
71+
self.active_connections: List[WebSocket] = []
72+
self._loop = None
73+
74+
async def connect(self, websocket: WebSocket):
75+
await websocket.accept()
76+
self.active_connections.append(websocket)
77+
logger.info(f"WebSocket connected. Total connections: {len(self.active_connections)}")
78+
79+
def disconnect(self, websocket: WebSocket):
80+
if websocket in self.active_connections:
81+
self.active_connections.remove(websocket)
82+
logger.info(f"WebSocket disconnected. Total connections: {len(self.active_connections)}")
83+
84+
def broadcast_file_update(self, update_type: str, file_path: str):
85+
"""Broadcast file update to all connected clients."""
86+
message = {"type": update_type, "path": file_path, "timestamp": time.time()}
87+
json_message = json.dumps(message)
88+
89+
# Broadcast to all active connections
90+
for connection in self.active_connections:
91+
try:
92+
asyncio.run_coroutine_threadsafe(connection.send_text(json_message), self._loop)
93+
except Exception as e:
94+
logger.error(f"Failed to send message to WebSocket: {e}")
95+
# Remove broken connection
96+
self.active_connections.remove(connection)
97+
98+
99+
class LogsServer(ViteServer):
100+
"""
101+
Enhanced server for serving Vite-built SPA with file watching and WebSocket support.
102+
103+
This server extends ViteServer to add:
104+
- File system watching
105+
- WebSocket connections for real-time updates
106+
- Live log streaming
107+
"""
108+
109+
def __init__(
110+
self,
111+
build_dir: str = os.path.abspath(
112+
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "vite-app", "dist")
113+
),
114+
host: str = "localhost",
115+
port: int = 4789,
116+
index_file: str = "index.html",
117+
watch_paths: Optional[List[str]] = None,
118+
):
119+
super().__init__(build_dir, host, port, index_file)
120+
121+
# Initialize WebSocket manager
122+
self.websocket_manager = WebSocketManager()
123+
124+
# Set up file watching
125+
self.watch_paths = watch_paths or [os.getcwd()]
126+
self.observer = Observer()
127+
self.file_watcher = FileWatcher(self.websocket_manager)
128+
129+
# Add WebSocket endpoint
130+
self._setup_websocket_routes()
131+
132+
logger.info(f"LogsServer initialized on {host}:{port}")
133+
logger.info(f"Watching paths: {self.watch_paths}")
134+
135+
def _setup_websocket_routes(self):
136+
"""Set up WebSocket routes for real-time communication."""
137+
138+
@self.app.websocket("/ws")
139+
async def websocket_endpoint(websocket: WebSocket):
140+
await self.websocket_manager.connect(websocket)
141+
try:
142+
while True:
143+
# Keep connection alive
144+
await websocket.receive_text()
145+
except WebSocketDisconnect:
146+
self.websocket_manager.disconnect(websocket)
147+
except Exception as e:
148+
logger.error(f"WebSocket error: {e}")
149+
self.websocket_manager.disconnect(websocket)
150+
151+
@self.app.get("/api/status")
152+
async def status():
153+
"""Get server status including active connections."""
154+
return {
155+
"status": "ok",
156+
"build_dir": str(self.build_dir),
157+
"active_connections": len(self.websocket_manager.active_connections),
158+
"watch_paths": self.watch_paths,
159+
}
160+
161+
def start_file_watching(self):
162+
"""Start watching file system for changes."""
163+
for path in self.watch_paths:
164+
if os.path.exists(path):
165+
self.observer.schedule(self.file_watcher, path, recursive=True)
166+
logger.info(f"Started watching: {path}")
167+
else:
168+
logger.warning(f"Watch path does not exist: {path}")
169+
170+
self.observer.start()
171+
logger.info("File watching started")
172+
173+
def stop_file_watching(self):
174+
"""Stop watching file system."""
175+
self.observer.stop()
176+
self.observer.join()
177+
logger.info("File watching stopped")
178+
179+
async def run_async(self, reload: bool = False):
180+
"""
181+
Run the logs server asynchronously with file watching.
182+
183+
Args:
184+
reload: Whether to enable auto-reload (default: False)
185+
"""
186+
try:
187+
# Start file watching
188+
self.start_file_watching()
189+
190+
logger.info(f"Starting LogsServer on {self.host}:{self.port}")
191+
logger.info(f"Serving files from: {self.build_dir}")
192+
logger.info("WebSocket endpoint available at /ws")
193+
194+
# Store the event loop for WebSocket manager
195+
self.websocket_manager._loop = asyncio.get_running_loop()
196+
197+
config = uvicorn.Config(self.app, host=self.host, port=self.port, reload=reload, log_level="info")
198+
server = uvicorn.Server(config)
199+
await server.serve()
200+
except KeyboardInterrupt:
201+
logger.info("Shutting down LogsServer...")
202+
finally:
203+
self.stop_file_watching()
204+
205+
def run(self, reload: bool = False):
206+
"""
207+
Run the logs server with file watching.
208+
209+
Args:
210+
reload: Whether to enable auto-reload (default: False)
211+
"""
212+
asyncio.run(self.run_async(reload))
213+
214+
215+
def serve_logs(
216+
host: str = "localhost",
217+
port: int = 4789,
218+
watch_paths: Optional[List[str]] = None,
219+
reload: bool = False,
220+
):
221+
"""
222+
Convenience function to create and run a LogsServer.
223+
224+
Args:
225+
build_dir: Path to the Vite build output directory
226+
host: Host to bind the server to
227+
port: Port to bind the server to (default: 4789 for logs)
228+
index_file: Name of the main index file
229+
watch_paths: List of paths to watch for file changes
230+
reload: Whether to enable auto-reload
231+
"""
232+
server = LogsServer(host=host, port=port, watch_paths=watch_paths)
233+
server.run(reload=reload)

0 commit comments

Comments
 (0)