KAOS/code/internal/server/server.go
djuka b3645beea0 T22: Prijava — dva moda (klijent forma + operater chat sa Claude API)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:38:05 +00:00

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,
}
}