// Package app assembles the application and owns process-level lifecycles. package app import ( "context" "errors" "fmt" "log" "net/http" "time" "fire-safety-ymd/internal/config" "fire-safety-ymd/internal/domain" "fire-safety-ymd/internal/handler" "fire-safety-ymd/internal/integration/superagent" "fire-safety-ymd/internal/repository" "fire-safety-ymd/internal/service" ) const ( readHeaderTimeout = 5 * time.Second readTimeout = 15 * time.Second writeTimeout = 35 * time.Second idleTimeout = 60 * time.Second shutdownTimeout = 10 * time.Second readinessTimeout = 30 * time.Second ) // Application owns the HTTP server and coordinates graceful shutdown. type Application struct { server *http.Server close func() } // New wires the application's current dependencies. func New(ctx context.Context, cfg config.Config) (*Application, error) { routerOptions := handler.RouterOptions{} closeDependencies := func() {} if cfg.Chat.PageEnabled { if !cfg.Chat.Enabled { return nil, fmt.Errorf("initialize chat page: chat must be enabled") } if cfg.Chat.CompatAppID == "" { return nil, fmt.Errorf("initialize chat page: compatibility app ID is required") } if len(cfg.Chat.AllowedOrigins) == 0 { return nil, fmt.Errorf("initialize chat page: at least one allowed origin is required") } } if cfg.Chat.Enabled { if !cfg.SuperAgent.Enabled { return nil, fmt.Errorf("initialize chat: SuperAgent must be enabled") } if cfg.Chat.AllowLegacyShortToken { log.Printf("security warning: legacy short chat auth token compatibility is enabled") } superAgentClient, err := superagent.NewHTTPClient(superagent.Config{ Enabled: cfg.SuperAgent.Enabled, BaseURL: cfg.SuperAgent.BaseURL, APIKey: cfg.SuperAgent.OpenAPIKey, IncludeTrace: cfg.SuperAgent.IncludeTrace, ConnectTimeout: cfg.SuperAgent.ConnectTimeout, RecoveryMaxAttempts: cfg.SuperAgent.RecoveryMaxAttempts, RecoveryInitialBackoff: cfg.SuperAgent.RecoveryInitialBackoff, MaxMessageBytes: cfg.SuperAgent.MaxMessageBytes, }) if err != nil { return nil, fmt.Errorf("initialize SuperAgent client: %w", err) } chatAgent, err := superagent.NewChatAgentAdapter(superAgentClient) if err != nil { return nil, fmt.Errorf("initialize SuperAgent chat adapter: %w", err) } chatService, err := service.NewChatService(chatAgent, service.ChatOptions{ ExternalSubjectID: cfg.Chat.SubjectID, MaxMessageBytes: cfg.SuperAgent.MaxMessageBytes, SessionTTL: cfg.Chat.SessionTTL, MaxSessions: cfg.Chat.MaxSessions, }) if err != nil { return nil, fmt.Errorf("initialize chat service: %w", err) } chatHandler, err := handler.NewChatHandler(chatService, handler.ChatOptions{ AuthToken: cfg.Chat.AuthToken, AllowLegacyShortToken: cfg.Chat.AllowLegacyShortToken, AllowedOrigins: cfg.Chat.AllowedOrigins, MaxBodyBytes: cfg.Chat.MaxBodyBytes, RunTimeout: cfg.Chat.RunTimeout, Logger: log.Default(), }) if err != nil { return nil, fmt.Errorf("initialize chat handler: %w", err) } routerOptions.Chat = chatHandler if cfg.Chat.CompatAppID != "" { dashScopeChatHandler, err := handler.NewDashScopeChatHandler(chatService, handler.DashScopeChatOptions{ AppID: cfg.Chat.CompatAppID, AuthToken: cfg.Chat.AuthToken, AllowLegacyShortToken: cfg.Chat.AllowLegacyShortToken, AllowedOrigins: cfg.Chat.AllowedOrigins, MaxBodyBytes: cfg.Chat.MaxBodyBytes, RunTimeout: cfg.Chat.RunTimeout, Logger: log.Default(), }) if err != nil { return nil, fmt.Errorf("initialize DashScope-compatible chat handler: %w", err) } routerOptions.DashScopeChat = dashScopeChatHandler } if cfg.Chat.PageEnabled { chatPageHandler, err := handler.NewChatPageHandler(handler.ChatPageOptions{ AppID: cfg.Chat.CompatAppID, }) if err != nil { return nil, fmt.Errorf("initialize chat page handler: %w", err) } routerOptions.ChatPage = chatPageHandler } } if cfg.MCP.Enabled { log.Printf("MCP data scope mode=%s", cfg.MCP.ScopeMode) postGIS, err := repository.OpenPostGIS(ctx, repository.PostGISOptions{ DSN: cfg.PostGIS.DSN, MaxConns: cfg.PostGIS.MaxConns, ConnectTimeout: cfg.PostGIS.ConnectTimeout, QueryTimeout: cfg.PostGIS.QueryTimeout, }) if err != nil { return nil, fmt.Errorf("initialize PostGIS: %w", err) } closeDependencies = postGIS.Close readinessCtx, cancel := context.WithTimeout(ctx, readinessTimeout) report, err := postGIS.ValidateForMCP(readinessCtx, cfg.PostGIS.ExpectedSRID) cancel() if err != nil { postGIS.Close() return nil, fmt.Errorf("validate PostGIS for MCP: %w", err) } for _, warning := range report.Warnings { log.Printf("PostGIS readiness warning=%s", warning) } spatialService, err := service.NewSpatialService(postGIS, domain.SpatialScope{ AllTowns: cfg.MCP.ScopeMode == config.MCPScopeModeAll, AllowedTowns: cfg.MCP.AllowedTowns, }, cfg.PostGIS.QueryTimeout) if err != nil { postGIS.Close() return nil, fmt.Errorf("initialize spatial service: %w", err) } mcpHandler, err := handler.NewMCPHandler(spatialService, handler.MCPOptions{ AuthToken: cfg.MCP.AuthToken, MaxBodyBytes: cfg.MCP.MaxBodyBytes, ToolTimeout: cfg.MCP.ToolTimeout, Logger: log.Default(), }) if err != nil { postGIS.Close() return nil, fmt.Errorf("initialize MCP handler: %w", err) } routerOptions.MCP = mcpHandler } return &Application{ server: &http.Server{ Addr: cfg.HTTPAddress, Handler: handler.NewRouter(routerOptions), ReadHeaderTimeout: readHeaderTimeout, ReadTimeout: readTimeout, WriteTimeout: writeTimeout, IdleTimeout: idleTimeout, }, close: closeDependencies, }, nil } // Run starts the HTTP server and shuts it down when ctx is canceled. func (a *Application) Run(ctx context.Context) error { defer a.close() serverErr := make(chan error, 1) go func() { serverErr <- a.server.ListenAndServe() }() select { case err := <-serverErr: return normalizeServerError(err) case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() if err := a.server.Shutdown(shutdownCtx); err != nil { return fmt.Errorf("shut down HTTP server: %w", err) } return normalizeServerError(<-serverErr) } } func normalizeServerError(err error) error { if err == nil || errors.Is(err, http.ErrServerClosed) { return nil } return fmt.Errorf("serve HTTP: %w", err) }