package daemon import ( "context" "encoding/json" "fmt" "net" "net/http" "os" "time" "github.com/multica-ai/multica/server/internal/daemon/repocache" ) // HealthResponse is returned by the daemon's local health endpoint. type HealthResponse struct { Status string `json:"status"` PID int `json:"pid"` Uptime string `json:"uptime"` DaemonID string `json:"daemon_id"` DeviceName string `json:"device_name"` ServerURL string `json:"server_url"` CLIVersion string `json:"cli_version"` ActiveTaskCount int64 `json:"active_task_count"` Agents []string `json:"agents"` Workspaces []healthWorkspace `json:"workspaces"` } type healthWorkspace struct { ID string `json:"id"` Runtimes []string `json:"runtimes"` } // listenHealth binds the health port. Returns the listener or an error if // another daemon is already running (port taken). func (d *Daemon) listenHealth() (net.Listener, error) { addr := fmt.Sprintf("127.0.0.1:%d", d.cfg.HealthPort) ln, err := net.Listen("tcp", addr) if err != nil { return nil, fmt.Errorf("another daemon is already running on %s: %w", addr, err) } return ln, nil } // repoCheckoutRequest is the body of a POST /repo/checkout request. type repoCheckoutRequest struct { URL string `json:"url"` WorkspaceID string `json:"workspace_id"` WorkDir string `json:"workdir"` AgentName string `json:"agent_name"` TaskID string `json:"task_id"` } // healthHandler returns the /health HTTP handler. Extracted from serveHealth // so tests can exercise it without spinning up a listener. func (d *Daemon) healthHandler(startedAt time.Time) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { d.mu.Lock() var wsList []healthWorkspace for id, ws := range d.workspaces { wsList = append(wsList, healthWorkspace{ ID: id, Runtimes: ws.runtimeIDs, }) } d.mu.Unlock() agents := make([]string, 0, len(d.cfg.Agents)) for name := range d.cfg.Agents { agents = append(agents, name) } resp := HealthResponse{ Status: "running", PID: os.Getpid(), Uptime: time.Since(startedAt).Truncate(time.Second).String(), DaemonID: d.cfg.DaemonID, DeviceName: d.cfg.DeviceName, ServerURL: d.cfg.ServerBaseURL, CLIVersion: d.cfg.CLIVersion, ActiveTaskCount: d.activeTasks.Load(), Agents: agents, Workspaces: wsList, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } } // serveHealth runs the health HTTP server on the given listener. // Blocks until ctx is cancelled. func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) { mux := http.NewServeMux() mux.HandleFunc("/health", d.healthHandler(startedAt)) mux.HandleFunc("/repo/checkout", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var req repoCheckoutRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) return } if req.URL == "" { http.Error(w, "url is required", http.StatusBadRequest) return } if req.WorkDir == "" { http.Error(w, "workdir is required", http.StatusBadRequest) return } if d.repoCache == nil { http.Error(w, "repo cache not initialized", http.StatusInternalServerError) return } result, err := d.repoCache.CreateWorktree(repocache.WorktreeParams{ WorkspaceID: req.WorkspaceID, RepoURL: req.URL, WorkDir: req.WorkDir, AgentName: req.AgentName, TaskID: req.TaskID, }) if err != nil { d.logger.Error("repo checkout failed", "url", req.URL, "error", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(result) }) srv := &http.Server{Handler: mux} go func() { <-ctx.Done() srv.Close() }() d.logger.Info("health server listening", "addr", ln.Addr().String()) if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { d.logger.Warn("health server error", "error", err) } }