458 lines
12 KiB
Go
458 lines
12 KiB
Go
// Package server implements the HTTP server and API for the KAOS dashboard.
|
|
package server
|
|
|
|
import (
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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()
|
|
|
|
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 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 {
|
|
// 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]
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|