package web import ( "encoding/json" "errors" "net/http" "strings" "time" "gitgud.io/mike/mpv-manager/pkg/constants" "gitgud.io/mike/mpv-manager/pkg/log" ) // ============================================================================ // Job API Endpoints (New Job Manager System) // ============================================================================ // handleJobRoutes routes /api/jobs/{id} to the appropriate handler based on method func (s *Server) handleJobRoutes(w http.ResponseWriter, r *http.Request) { // Extract job ID from path: /api/jobs/{id} path := r.URL.Path prefix := "/api/jobs/" if path == "/api/jobs/" || path == "/api/jobs" { // No job ID provided sendJSONError(w, http.StatusBadRequest, "Job ID required") return } jobID := strings.TrimPrefix(path, prefix) if jobID == "" || strings.Contains(jobID, "/") { sendJSONError(w, http.StatusBadRequest, "Invalid job ID") return } switch r.Method { case http.MethodGet: s.handleJobGetAPI(w, r) case http.MethodDelete: s.handleJobCancelAPI(w, r) default: sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") } } // handleJobsListAPI returns all active and recent jobs func (s *Server) handleJobsListAPI(w http.ResponseWriter, r *http.Request) { activeJobs, recentJobs := s.jobManager.jobSummaries() // Format active jobs active := make([]map[string]interface{}, 0, len(activeJobs)) for _, job := range activeJobs { active = append(active, jobToAPIResponse(job)) } // Format recent jobs recent := make([]map[string]interface{}, 0, len(recentJobs)) for _, job := range recentJobs { recent = append(recent, jobToAPIResponse(job)) } w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "active": active, "recent": recent, "total": len(active) + len(recent), }) } // handleJobGetAPI returns details for a specific job func (s *Server) handleJobGetAPI(w http.ResponseWriter, r *http.Request) { // Extract job ID from path: /api/jobs/{id} path := r.URL.Path prefix := "/api/jobs/" if !strings.HasPrefix(path, prefix) { sendJSONError(w, http.StatusBadRequest, "Invalid path") return } jobID := strings.TrimPrefix(path, prefix) if jobID == "" { sendJSONError(w, http.StatusBadRequest, "Job ID required") return } job, exists := s.jobManager.JobSnapshot(jobID) if !exists { // Also check recent jobs recentJobs := s.jobManager.GetRecentJobs() for _, rj := range recentJobs { if rj.ID == jobID { job = rj exists = true break } } } if !exists { sendJSONError(w, http.StatusNotFound, "Job not found") return } response := jobToAPIResponse(job) response["output"] = job.Output // Include full output for individual job w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(response) } // handleJobCancelAPI cancels (or removes) a job func (s *Server) handleJobCancelAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { sendJSONError(w, http.StatusMethodNotAllowed, "Method not allowed") return } // Extract job ID from path: /api/jobs/{id} path := r.URL.Path prefix := "/api/jobs/" if !strings.HasPrefix(path, prefix) { sendJSONError(w, http.StatusBadRequest, "Invalid path") return } jobID := strings.TrimPrefix(path, prefix) if jobID == "" { sendJSONError(w, http.StatusBadRequest, "Job ID required") return } // Use the new CancelJob method which properly handles context cancellation err := s.jobManager.CancelJob(jobID) if err != nil { if errors.Is(err, ErrJobNotFound) { sendJSONError(w, http.StatusNotFound, err.Error()) return } status := http.StatusBadRequest if errors.Is(err, ErrJobCommitStarted) { status = http.StatusConflict } sendJSONError(w, status, err.Error()) return } log.Info(constants.LogPrefixAPI + "Job cancellation requested: " + jobID) w.Header().Set("Content-Type", constants.ContentTypeJSON) json.NewEncoder(w).Encode(map[string]interface{}{ "status": "cancelling", "job_id": jobID, "message": "Cancellation requested; waiting for the worker to stop", }) } // jobToAPIResponse converts a Job snapshot to an API response map func jobToAPIResponse(job Job) map[string]interface{} { response := map[string]interface{}{ "id": job.ID, "type": job.Type, "methodId": job.MethodID, "appName": job.AppName, "status": job.Status, "phase": job.Phase, "progress": job.Progress, "message": job.Message, "startedAt": job.StartedAt.Format(time.RFC3339), "durationSecs": job.Duration().Seconds(), } if job.Error != "" { response["error"] = job.Error } if job.ErrorDetails != "" { response["errorDetails"] = job.ErrorDetails } if job.CompletedAt != nil { response["completedAt"] = job.CompletedAt.Format(time.RFC3339) } return response }