diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py new file mode 100644 index 000000000..296655bf1 --- /dev/null +++ b/backend/app/controller/health_controller.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter(tags=["Health"]) + + +class HealthResponse(BaseModel): + status: str + service: str + + +@router.get("/health", name="health check", response_model=HealthResponse) +async def health_check(): + """Health check endpoint for verifying backend is ready to accept requests.""" + return HealthResponse(status="ok", service="eigent") + diff --git a/backend/app/router.py b/backend/app/router.py index c311d316e..3fc6cb257 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -3,7 +3,7 @@ Centralized router registration for the Eigent API. All routers are explicitly registered here for better visibility and maintainability. """ from fastapi import FastAPI -from app.controller import chat_controller, model_controller, task_controller, tool_controller +from app.controller import chat_controller, model_controller, task_controller, tool_controller, health_controller from utils import traceroot_wrapper as traceroot logger = traceroot.get_logger("router") @@ -23,6 +23,11 @@ def register_routers(app: FastAPI, prefix: str = "") -> None: prefix: Optional global prefix for all routes (e.g., "/api") """ routers_config = [ + { + "router": health_controller.router, + "tags": ["Health"], + "description": "Health check endpoint for service readiness" + }, { "router": chat_controller.router, "tags": ["chat"], diff --git a/electron/main/init.ts b/electron/main/init.ts index 11ed6ef38..b09db6595 100644 --- a/electron/main/init.ts +++ b/electron/main/init.ts @@ -4,6 +4,7 @@ import log from 'electron-log' import fs from 'fs' import path from 'path' import * as net from "net"; +import * as http from "http"; import { ipcMain, BrowserWindow, app } from 'electron' import { promisify } from 'util' import { detectInstallationLogs, PromiseReturnType } from "./install-deps"; @@ -195,21 +196,77 @@ export async function startBackend(setPort?: (port: number) => void): Promise { if (!started) { + if (healthCheckInterval) clearInterval(healthCheckInterval); node_process.kill(); reject(new Error('Backend failed to start within timeout')); } }, 30000); // 30 second timeout + // Helper function to poll health endpoint + const pollHealthEndpoint = (): void => { + let attempts = 0; + const maxAttempts = 20; // 5 seconds total (20 * 250ms) + const intervalMs = 250; + + healthCheckInterval = setInterval(() => { + attempts++; + const healthUrl = `http://127.0.0.1:${port}/health`; + + const req = http.get(healthUrl, { timeout: 1000 }, (res) => { + if (res.statusCode === 200) { + log.info(`Backend health check passed after ${attempts} attempts`); + started = true; + clearTimeout(startTimeout); + if (healthCheckInterval) clearInterval(healthCheckInterval); + resolve(node_process); + } else { + // Non-200 status (e.g., 404), continue polling unless max attempts reached + if (attempts >= maxAttempts) { + log.error(`Backend health check failed after ${attempts} attempts with status ${res.statusCode}`); + started = true; + clearTimeout(startTimeout); + if (healthCheckInterval) clearInterval(healthCheckInterval); + node_process.kill(); + reject(new Error(`Backend health check failed: HTTP ${res.statusCode}`)); + } + } + }); + + req.on('error', () => { + // Connection error - backend might not be ready yet, continue polling + if (attempts >= maxAttempts) { + log.error(`Backend health check failed after ${attempts} attempts: unable to connect`); + started = true; + clearTimeout(startTimeout); + if (healthCheckInterval) clearInterval(healthCheckInterval); + node_process.kill(); + reject(new Error('Backend health check failed: unable to connect')); + } + }); + + req.on('timeout', () => { + req.destroy(); + if (attempts >= maxAttempts) { + log.error(`Backend health check timed out after ${attempts} attempts`); + started = true; + clearTimeout(startTimeout); + if (healthCheckInterval) clearInterval(healthCheckInterval); + node_process.kill(); + reject(new Error('Backend health check timed out')); + } + }); + }, intervalMs); + }; node_process.stdout.on('data', (data) => { displayFilteredLogs(data); // check output content, judge if start success if (!started && data.toString().includes("Uvicorn running on")) { - started = true; - clearTimeout(startTimeout); - resolve(node_process); + log.info('Uvicorn startup detected, starting health check polling...'); + pollHealthEndpoint(); } }); @@ -217,9 +274,8 @@ export async function startBackend(setPort?: (port: number) => void): Promise void): Promise void): Promise { clearTimeout(startTimeout); + if (healthCheckInterval) clearInterval(healthCheckInterval); if (!started) { reject(new Error(`fastapi exited with code ${code}`)); } diff --git a/server/app/controller/chat/history_controller.py b/server/app/controller/chat/history_controller.py index f58b29995..9dd6f2a30 100644 --- a/server/app/controller/chat/history_controller.py +++ b/server/app/controller/chat/history_controller.py @@ -3,7 +3,7 @@ from fastapi_pagination import Page from fastapi_pagination.ext.sqlmodel import paginate from app.model.chat.chat_history import ChatHistoryOut, ChatHistoryIn, ChatHistory, ChatHistoryUpdate from fastapi_babel import _ -from sqlmodel import Session, select, desc +from sqlmodel import Session, select, desc, case from app.component.auth import Auth, auth_must from app.component.database import session from utils import traceroot_wrapper as traceroot @@ -38,7 +38,19 @@ def create_chat_history(data: ChatHistoryIn, session: Session = Depends(session) def list_chat_history(session: Session = Depends(session), auth: Auth = Depends(auth_must)) -> Page[ChatHistoryOut]: """List chat histories for current user.""" user_id = auth.user.id - stmt = select(ChatHistory).where(ChatHistory.user_id == user_id).order_by(desc(ChatHistory.created_at)) + + # Order by created_at descending, but fallback to id descending for old records without timestamps + # This ensures newer records with timestamps come first, followed by old records ordered by id + stmt = ( + select(ChatHistory) + .where(ChatHistory.user_id == user_id) + .order_by( + desc(case((ChatHistory.created_at.is_(None), 0), else_=1)), # Non-null created_at first + desc(ChatHistory.created_at), # Then by created_at descending + desc(ChatHistory.id) # Finally by id descending for records with same/null created_at + ) + ) + result = paginate(session, stmt) total = result.total if hasattr(result, 'total') else 0 logger.debug("Chat histories listed", extra={"user_id": user_id, "total": total}) diff --git a/server/app/model/chat/chat_history.py b/server/app/model/chat/chat_history.py index 6dd7775a1..79720d87e 100644 --- a/server/app/model/chat/chat_history.py +++ b/server/app/model/chat/chat_history.py @@ -2,6 +2,7 @@ from sqlalchemy import Float, Integer from sqlmodel import Field, SmallInteger, Column, JSON, String from typing import Optional from enum import IntEnum +from datetime import datetime from sqlalchemy_utils import ChoiceType from app.model.abstract.model import AbstractModel, DefaultTimes from pydantic import BaseModel, model_validator @@ -13,6 +14,16 @@ class ChatStatus(IntEnum): class ChatHistory(AbstractModel, DefaultTimes, table=True): + """ + Chat history model with timestamp tracking. + + Inherits from DefaultTimes which provides: + - created_at: timestamp when record is created (auto-populated) + - updated_at: timestamp when record is last modified (auto-updated) + - deleted_at: timestamp for soft deletion (nullable) + + For legacy records without timestamps, sorting falls back to id ordering. + """ id: int = Field(default=None, primary_key=True) user_id: int = Field(index=True) task_id: str = Field(index=True, unique=True) @@ -70,13 +81,22 @@ class ChatHistoryOut(BaseModel): summary: str | None = None tokens: int status: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None @model_validator(mode="after") def fill_project_id_from_task_id(self): - """fill by task_id when project_id is None""" + """Fill project_id from task_id when project_id is None""" if self.project_id is None: self.project_id = self.task_id return self + + @model_validator(mode="after") + def handle_legacy_timestamps(self): + """Handle legacy records that might not have timestamp fields""" + # For old records without timestamps, we rely on database-level defaults + # The sorting in the controller will handle ordering appropriately + return self class ChatHistoryUpdate(BaseModel):