// Package server implements the HTTP server and API for the KAOS dashboard. package server import ( "fmt" "io/fs" "net/http" "os" "path/filepath" "strings" "sync" "time" "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 console *consoleManager events *eventBroker chatMu sync.RWMutex chats map[string]*chatState } // 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, console: newConsoleManager(), events: newEventBroker(cfg.TasksDir), chats: make(map[string]*chatState), } // 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.POST("/task/:id/run", s.handleRunTask) s.Router.GET("/report/:id", s.handleReport) // SSE events s.Router.GET("/events", s.handleEvents) // Search route s.Router.GET("/search", s.handleSearch) // Console routes s.Router.GET("/console", s.handleConsolePage) s.Router.POST("/console/exec", s.handleConsoleExec) s.Router.GET("/console/stream/:id", s.handleConsoleStream) s.Router.POST("/console/kill/:session", s.handleConsoleKill) s.Router.GET("/console/sessions", s.handleConsoleSessions) s.Router.GET("/console/history/:session", s.handleConsoleHistory) // Docs routes s.Router.GET("/docs", s.handleDocsList) s.Router.GET("/docs/*path", s.handleDocsView) // Submit routes s.Router.GET("/submit", s.handleSubmitPage) s.Router.POST("/submit/simple", s.handleSimpleSubmit) s.Router.POST("/submit/chat", s.handleChatSubmit) s.Router.GET("/submit/chat/stream/:id", s.handleChatStream) } // 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 } // Append timestamp to moved file newPath := filepath.Join(s.Config.TasksDir, toFolder, id+".md") if label, ok := moveEventLabel[toFolder]; ok { appendTimestamp(newPath, label) } 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 } // Append timestamp to moved file newPath := filepath.Join(s.Config.TasksDir, toFolder, id+".md") if label, ok := moveEventLabel[toFolder]; ok { appendTimestamp(newPath, label) } // Re-scan and return updated dashboard s.handleDashboard(c) } // handleRunTask launches a Claude Code agent for a task in a clean session. func (s *Server) handleRunTask(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 } // Check status if task.Status == "active" { c.JSON(http.StatusBadRequest, gin.H{"error": id + " je već aktivan"}) return } if task.Status == "done" { c.JSON(http.StatusBadRequest, gin.H{"error": id + " je već završen"}) return } // Check dependencies for backlog tasks if task.Status == "backlog" { doneSet := make(map[string]bool) for _, t := range tasks { if t.Status == "done" { doneSet[t.ID] = true } } for _, dep := range task.DependsOn { if !doneSet[dep] { c.JSON(http.StatusBadRequest, gin.H{"error": "zavisnost " + dep + " nije ispunjena"}) return } } // Move backlog → ready first if err := supervisor.MoveTask(s.Config.TasksDir, id, "backlog", "ready"); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } readyPath := filepath.Join(s.Config.TasksDir, "ready", id+".md") appendTimestamp(readyPath, "Odobren (→ready)") task.Status = "ready" } // Find free session sessionIdx := -1 for i := 0; i < 2; i++ { sess := s.console.getSession(i) sess.mu.Lock() if sess.status == "idle" { sessionIdx = i sess.mu.Unlock() break } sess.mu.Unlock() } if sessionIdx == -1 { c.JSON(http.StatusConflict, gin.H{"error": "obe sesije su zauzete"}) return } // Build the prompt prompt := "Pročitaj CLAUDE.md u root-u projekta. Tvoj task: TASKS/ready/" + id + ".md — Pročitaj task fajl i uradi šta piše. Prati pravila iz CLAUDE.md." // Start in the session session := s.console.getSession(sessionIdx) execID := s.console.nextExecID() session.mu.Lock() session.status = "running" session.execID = execID session.taskID = id session.output = nil session.history = append(session.history, historyEntry{ Command: "pusti " + id, ExecID: execID, Timestamp: timeNow(), Status: "running", }) session.mu.Unlock() // Append "Pokrenut" timestamp taskPath := filepath.Join(s.Config.TasksDir, "ready", id+".md") appendTimestamp(taskPath, "Pokrenut") go s.runCommand(session, prompt, execID) c.JSON(http.StatusOK, gin.H{ "status": "started", "session": sessionIdx + 1, "exec_id": execID, }) } // handleReport serves a task report in the overlay modal. // If a report file exists, render it. Otherwise, show the task content. func (s *Server) handleReport(c *gin.Context) { id := strings.ToUpper(c.Param("id")) reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md") var content []byte var title string if data, err := os.ReadFile(reportPath); err == nil { content = data title = id + " — Izveštaj" } else { // No report — show the task file itself 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 } data, err := os.ReadFile(task.FilePath) if err != nil { c.String(http.StatusNotFound, "Fajl nije pronađen") return } content = data title = id + " — " + task.Title } c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, renderReportModal(id, title, string(content))) } // Run starts the HTTP server. func (s *Server) Run() error { // Start SSE polling for task state changes s.events.startPolling(func() string { tasks, err := supervisor.ScanTasks(s.Config.TasksDir) if err != nil { return "" } columns := groupByStatus(tasks) return renderBoardFragment(columns) }) 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] } // moveEventLabel returns a human-readable label for a folder transition. var moveEventLabel = map[string]string{ "ready": "Odobren (→ready)", "active": "Pokrenut (→active)", "review": "Završen (→review)", "done": "Odobren (→done)", } // appendTimestamp adds a timestamp row to the task file's "## Vremena" table. // If the table doesn't exist yet, it creates it. func appendTimestamp(filePath, event string) error { content, err := os.ReadFile(filePath) if err != nil { return err } now := time.Now().Format("2006-01-02 15:04") row := fmt.Sprintf("| %s | %s |", event, now) text := string(content) marker := "## Vremena" if strings.Contains(text, marker) { // Table exists — append row before the last line break at end text = strings.TrimRight(text, "\n") + "\n" + row + "\n" } else { // Create the table section at the end section := fmt.Sprintf("\n%s\n\n| Događaj | Vreme |\n|---------|-------|\n%s\n", marker, row) text = strings.TrimRight(text, "\n") + "\n" + section } return os.WriteFile(filePath, []byte(text), 0644) } 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, } }