KAOS/code/internal/server/server.go
djuka ac72ca6f52 Pusti: premešta task ready→active bez pokretanja claude sesije
- handleRunTask samo premešta task iz ready/ u active/ sa timestampom
- Uklonjena zavisnost od console sesija — konzola je nezavisna
- Korisnik pokreće claude ručno iz konzole terminala
- Ažurirani testovi (6 RunTask testova prolaze)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:09:30 +00:00

503 lines
13 KiB
Go

// 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)
s.Router.GET("/console/ws/:session", s.handleConsoleWS)
// Logs route
s.Router.GET("/api/logs/tail", s.handleLogsTail)
// 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"
}
// Move ready → active
if err := supervisor.MoveTask(s.Config.TasksDir, id, "ready", "active"); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Append "Pokrenut" timestamp
taskPath := filepath.Join(s.Config.TasksDir, "active", id+".md")
appendTimestamp(taskPath, "Pokrenut (→active)")
c.JSON(http.StatusOK, gin.H{
"status": "started",
"task": id,
})
}
// 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,
}
}