@@ -49,6 +49,10 @@ def create_app(config: MeshConfig) -> Starlette:
4949 _build_task : asyncio .Task | None = None # tracked to prevent GC
5050 _state_lock = asyncio .Lock ()
5151
52+ # Build history ring buffer — last 50 builds
53+ from collections import deque
54+ build_history : deque [dict ] = deque (maxlen = 50 )
55+
5256 # --- Term fencing helper (1C) ---
5357 async def _check_term (request : Request ) -> JSONResponse | None :
5458 """Check term from request headers. Returns 409 response if stale, None if OK."""
@@ -419,6 +423,44 @@ async def session_file(request: Request) -> Response:
419423 },
420424 )
421425
426+ async def builds_history (request : Request ) -> JSONResponse :
427+ """Return build history (last 50 builds, newest first)."""
428+ return JSONResponse ({"builds" : list (reversed (build_history ))})
429+
430+ async def logs_tail (request : Request ) -> JSONResponse :
431+ """Return recent structured log entries from the JSON log file."""
432+ limit = min (int (request .query_params .get ("limit" , "100" )), 500 )
433+ level_filter = request .query_params .get ("level" , "" ).upper ()
434+ event_filter = request .query_params .get ("event" , "" )
435+
436+ log_path = mesh_dir / "logs" / "server.log"
437+ if not log_path .exists ():
438+ return JSONResponse ({"entries" : [], "log_file" : str (log_path )})
439+
440+ # Read last N lines efficiently (read from end)
441+ entries : list [dict ] = []
442+ try :
443+ raw_lines = log_path .read_text (encoding = "utf-8" , errors = "replace" ).splitlines ()
444+ for line in reversed (raw_lines ):
445+ if len (entries ) >= limit :
446+ break
447+ line = line .strip ()
448+ if not line :
449+ continue
450+ try :
451+ entry = json .loads (line )
452+ except json .JSONDecodeError :
453+ continue
454+ if level_filter and entry .get ("level" , "" ) != level_filter :
455+ continue
456+ if event_filter and entry .get ("event" , "" ) != event_filter :
457+ continue
458+ entries .append (entry )
459+ except OSError as exc :
460+ return JSONResponse ({"error" : f"Cannot read log: { exc } " }, status_code = 500 )
461+
462+ return JSONResponse ({"entries" : entries , "count" : len (entries )})
463+
422464 async def _run_build () -> None :
423465 nonlocal build_count , current_bundle_etag , current_bundle_path
424466
@@ -479,6 +521,15 @@ async def _run_build() -> None:
479521 "cached" : result .cached ,
480522 },
481523 )
524+ build_history .append ({
525+ "build_number" : local_build_count ,
526+ "status" : "success" ,
527+ "started_at" : time .strftime ("%Y-%m-%dT%H:%M:%SZ" , time .gmtime (build_start )),
528+ "duration" : round (build_duration , 1 ),
529+ "built" : result .built ,
530+ "cached" : result .cached ,
531+ "sessions_processed" : len (pre_build_unprocessed ),
532+ })
482533
483534 # 4. Run server deploy hooks
484535 if config .deploy .server_commands :
@@ -570,6 +621,14 @@ async def _run_build() -> None:
570621
571622 except Exception as exc :
572623 mesh_event (logger , logging .ERROR , f"Build failed: { exc } " , "build_failed" , {"error" : str (exc )})
624+ build_history .append ({
625+ "build_number" : build_count + 1 ,
626+ "status" : "failed" ,
627+ "started_at" : time .strftime ("%Y-%m-%dT%H:%M:%SZ" , time .gmtime (build_start )),
628+ "duration" : round (time .time () - build_start , 1 ),
629+ "error" : str (exc ),
630+ "sessions_processed" : len (pre_build_unprocessed ),
631+ })
573632
574633 # 9. Complete scheduler and check if another build is needed
575634 needs_another = await scheduler .complete_build ()
@@ -608,7 +667,9 @@ async def lifespan(app: Starlette):
608667 Route ("/api/v1/sessions/manifest" , sessions_manifest ),
609668 Route ("/api/v1/sessions/{session_id}/file" , session_file ),
610669 Route ("/api/v1/builds/status" , build_status ),
670+ Route ("/api/v1/builds/history" , builds_history ),
611671 Route ("/api/v1/builds/trigger" , trigger_build , methods = ["POST" ]),
672+ Route ("/api/v1/logs" , logs_tail ),
612673 Route ("/api/v1/artifacts/manifest" , artifact_manifest ),
613674 Route ("/api/v1/artifacts/bundle" , artifact_bundle ),
614675 Route ("/api/v1/search" , search , methods = ["POST" ]),
0 commit comments