// Package server implements the HTTP server and API for the KAOS dashboard. package server import ( "io/fs" "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" "github.com/dal/kaos/internal/config" "github.com/dal/kaos/internal/supervisor" "github.com/dal/kaos/web" ) // Server holds the HTTP server state. type Server struct { Config *config.Config Router *gin.Engine } // taskResponse is the JSON representation of a task. type taskResponse struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` Agent string `json:"agent"` Model string `json:"model"` DependsOn []string `json:"depends_on"` Description string `json:"description"` FilePath string `json:"file_path"` } // taskDetailResponse includes the raw file content. type taskDetailResponse struct { taskResponse Content string `json:"content"` } // validFolders defines all known task folders. var validFolders = map[string]bool{ "backlog": true, "ready": true, "active": true, "review": true, "done": true, } // allowedMoves defines which folder transitions the operator can make. // Agent-only transitions (ready→active, active→review) are forbidden. var allowedMoves = map[string]map[string]bool{ "backlog": {"ready": true}, "ready": {"backlog": true}, "review": {"done": true, "ready": true}, "done": {"review": true}, } // New creates a new Server with all routes configured. func New(cfg *config.Config) *Server { gin.SetMode(gin.ReleaseMode) router := gin.New() router.Use(gin.Recovery()) s := &Server{ Config: cfg, Router: router, } // No caching for dynamic routes — disk is the source of truth. router.Use(func(c *gin.Context) { if !strings.HasPrefix(c.Request.URL.Path, "/static") { c.Header("Cache-Control", "no-store, no-cache, must-revalidate") } c.Next() }) s.setupRoutes() return s } // setupRoutes configures all HTTP routes. func (s *Server) setupRoutes() { // Embedded static files staticFS, _ := fs.Sub(web.StaticFS, "static") s.Router.StaticFS("/static", http.FS(staticFS)) // API routes s.Router.GET("/api/tasks", s.apiGetTasks) s.Router.GET("/api/task/:id", s.apiGetTask) s.Router.POST("/api/task/:id/move", s.apiMoveTask) // HTML routes s.Router.GET("/", s.handleDashboard) s.Router.GET("/task/:id", s.handleTaskDetail) s.Router.POST("/task/:id/move", s.handleMoveTask) s.Router.GET("/report/:id", s.handleReport) // Docs routes s.Router.GET("/docs", s.handleDocsList) s.Router.GET("/docs/*path", s.handleDocsView) } // apiGetTasks returns all tasks as JSON. func (s *Server) apiGetTasks(c *gin.Context) { tasks, err := supervisor.ScanTasks(s.Config.TasksDir) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } resp := make([]taskResponse, len(tasks)) for i, t := range tasks { resp[i] = toTaskResponse(t) } c.JSON(http.StatusOK, resp) } // apiGetTask returns a single task with its file content as JSON. func (s *Server) apiGetTask(c *gin.Context) { id := strings.ToUpper(c.Param("id")) tasks, err := supervisor.ScanTasks(s.Config.TasksDir) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } task := supervisor.FindTask(tasks, id) if task == nil { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } content, _ := os.ReadFile(task.FilePath) resp := taskDetailResponse{ taskResponse: toTaskResponse(*task), Content: string(content), } c.JSON(http.StatusOK, resp) } // apiMoveTask moves a task to a different folder. func (s *Server) apiMoveTask(c *gin.Context) { id := strings.ToUpper(c.Param("id")) toFolder := c.Query("to") if !validFolders[toFolder] { c.JSON(http.StatusBadRequest, gin.H{"error": "nevažeći folder: " + toFolder}) return } tasks, err := supervisor.ScanTasks(s.Config.TasksDir) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } task := supervisor.FindTask(tasks, id) if task == nil { c.JSON(http.StatusNotFound, gin.H{"error": "task not found"}) return } if !isMoveAllowed(task.Status, toFolder) { c.JSON(http.StatusForbidden, gin.H{ "error": "premještanje " + task.Status + " → " + toFolder + " nije dozvoljeno", }) return } if err := supervisor.MoveTask(s.Config.TasksDir, id, task.Status, toFolder); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok", "moved": id, "to": toFolder}) } // handleDashboard serves the main dashboard page. func (s *Server) handleDashboard(c *gin.Context) { tasks, err := supervisor.ScanTasks(s.Config.TasksDir) if err != nil { c.String(http.StatusInternalServerError, "Greška: %v", err) return } columns := groupByStatus(tasks) c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, renderDashboard(columns)) } // handleTaskDetail serves task detail as HTML fragment for HTMX. func (s *Server) handleTaskDetail(c *gin.Context) { id := strings.ToUpper(c.Param("id")) tasks, err := supervisor.ScanTasks(s.Config.TasksDir) if err != nil { c.String(http.StatusInternalServerError, "Greška: %v", err) return } task := supervisor.FindTask(tasks, id) if task == nil { c.String(http.StatusNotFound, "Task %s nije pronađen", id) return } content, _ := os.ReadFile(task.FilePath) hasReport := reportExists(s.Config.TasksDir, id) c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, renderTaskDetail(*task, string(content), hasReport)) } // handleMoveTask moves a task and returns updated board HTML. func (s *Server) handleMoveTask(c *gin.Context) { id := strings.ToUpper(c.Param("id")) toFolder := c.Query("to") if !validFolders[toFolder] { c.String(http.StatusBadRequest, "Nevažeći folder: %s", toFolder) return } tasks, err := supervisor.ScanTasks(s.Config.TasksDir) if err != nil { c.String(http.StatusInternalServerError, "Greška: %v", err) return } task := supervisor.FindTask(tasks, id) if task == nil { c.String(http.StatusNotFound, "Task %s nije pronađen", id) return } if !isMoveAllowed(task.Status, toFolder) { c.String(http.StatusForbidden, "Premještanje %s → %s nije dozvoljeno", task.Status, toFolder) return } if err := supervisor.MoveTask(s.Config.TasksDir, id, task.Status, toFolder); err != nil { c.String(http.StatusInternalServerError, "Greška: %v", err) return } // Re-scan and return updated dashboard s.handleDashboard(c) } // handleReport serves a task report file. func (s *Server) handleReport(c *gin.Context) { id := strings.ToUpper(c.Param("id")) reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md") content, err := os.ReadFile(reportPath) if err != nil { c.String(http.StatusNotFound, "Izveštaj za %s nije pronađen", id) return } c.Header("Content-Type", "text/plain; charset=utf-8") c.String(http.StatusOK, string(content)) } // Run starts the HTTP server. func (s *Server) Run() error { return s.Router.Run(":" + s.Config.Port) } // groupByStatus organizes tasks into columns by status folder. func groupByStatus(tasks []supervisor.Task) map[string][]supervisor.Task { columns := map[string][]supervisor.Task{ "backlog": {}, "ready": {}, "active": {}, "review": {}, "done": {}, } for _, t := range tasks { columns[t.Status] = append(columns[t.Status], t) } return columns } // reportExists checks if a report file exists for a task. func reportExists(tasksDir, taskID string) bool { reportPath := filepath.Join(tasksDir, "reports", taskID+"-report.md") _, err := os.Stat(reportPath) return err == nil } // isMoveAllowed checks if a manual move from one folder to another is permitted. func isMoveAllowed(from, to string) bool { if from == to { return false } targets, ok := allowedMoves[from] if !ok { return false } return targets[to] } func toTaskResponse(t supervisor.Task) taskResponse { deps := t.DependsOn if deps == nil { deps = []string{} } return taskResponse{ ID: t.ID, Title: t.Title, Status: t.Status, Agent: t.Agent, Model: t.Model, DependsOn: deps, Description: t.Description, FilePath: t.FilePath, } }