- Zamena fiksnih 2 sesija sa taskSessionManager (map po task ID) - "Pusti" pokreće interaktivni claude u PTY, šalje task prompt - "Proveri" pokreće review claude sesiju za task u review/ - WS se konektuje na postojeću PTY sesiju po task ID-u - Konzola stranica dinamički prikazuje terminale za aktivne sesije - Replay buffer za reconnect na postojeće sesije - Novi testovi za session manager, prompt buildere, review endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
314 lines
7.0 KiB
Go
314 lines
7.0 KiB
Go
// Package server — submit.go handles task submission in two modes:
|
|
// client (simple form) and operator (chat via claude CLI).
|
|
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/dal/kaos/internal/supervisor"
|
|
)
|
|
|
|
// chatCounter generates unique chat IDs.
|
|
var chatCounter atomic.Int64
|
|
|
|
func nextChatID() string {
|
|
chatCounter.Add(1)
|
|
return fmt.Sprintf("chat-%d-%d", time.Now().Unix(), chatCounter.Load())
|
|
}
|
|
|
|
// cleanEnv returns the current environment with CLAUDECODE removed,
|
|
// so child claude processes don't inherit the parent's session.
|
|
func cleanEnv() []string {
|
|
var env []string
|
|
for _, e := range os.Environ() {
|
|
if !strings.HasPrefix(e, "CLAUDECODE=") {
|
|
env = append(env, e)
|
|
}
|
|
}
|
|
return env
|
|
}
|
|
|
|
// chatState manages an operator chat session backed by a claude CLI process.
|
|
type chatState struct {
|
|
mu sync.Mutex
|
|
id string
|
|
cmd *exec.Cmd
|
|
output []string
|
|
done bool
|
|
listeners map[chan string]bool
|
|
}
|
|
|
|
// nextTaskNumber finds the highest T{XX} number across all tasks and returns the next one.
|
|
func nextTaskNumber(tasksDir string) (string, error) {
|
|
tasks, err := supervisor.ScanTasks(tasksDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
maxNum := 0
|
|
re := regexp.MustCompile(`^T(\d+)$`)
|
|
for _, t := range tasks {
|
|
if matches := re.FindStringSubmatch(t.ID); matches != nil {
|
|
num, err := strconv.Atoi(matches[1])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if num > maxNum {
|
|
maxNum = num
|
|
}
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("T%02d", maxNum+1), nil
|
|
}
|
|
|
|
// handleSubmitPage serves the submission page.
|
|
func (s *Server) handleSubmitPage(c *gin.Context) {
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
c.String(http.StatusOK, renderSubmitPage())
|
|
}
|
|
|
|
// handleSimpleSubmit creates a task in backlog/ from the client form.
|
|
func (s *Server) handleSimpleSubmit(c *gin.Context) {
|
|
title := strings.TrimSpace(c.PostForm("title"))
|
|
desc := strings.TrimSpace(c.PostForm("description"))
|
|
priority := strings.TrimSpace(c.PostForm("priority"))
|
|
|
|
if title == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "naslov je obavezan"})
|
|
return
|
|
}
|
|
if priority == "" {
|
|
priority = "Srednji"
|
|
}
|
|
|
|
taskID, err := nextTaskNumber(s.Config.TasksDir)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
now := time.Now().Format("2006-01-02 15:04")
|
|
content := fmt.Sprintf(`# %s: %s
|
|
|
|
**Kreirao:** klijent (prijava)
|
|
**Datum:** %s
|
|
**Agent:** —
|
|
**Model:** —
|
|
**Zavisi od:** —
|
|
**Prioritet:** %s
|
|
**Izvor:** klijent
|
|
|
|
---
|
|
|
|
## Opis
|
|
|
|
%s
|
|
|
|
## Originalna prijava
|
|
|
|
%s
|
|
`, taskID, title, now, priority, desc, desc)
|
|
|
|
path := filepath.Join(s.Config.TasksDir, "backlog", taskID+".md")
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "task_id": taskID})
|
|
}
|
|
|
|
// handleChatSubmit spawns a claude CLI process with the operator's message.
|
|
func (s *Server) handleChatSubmit(c *gin.Context) {
|
|
var req struct {
|
|
Message string `json:"message"`
|
|
}
|
|
if err := c.BindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "neispravan zahtev"})
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(req.Message) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "poruka je obavezna"})
|
|
return
|
|
}
|
|
|
|
// Build prompt with context
|
|
tasks, _ := supervisor.ScanTasks(s.Config.TasksDir)
|
|
context := buildTaskContext(tasks)
|
|
|
|
projectRoot := filepath.Dir(s.Config.TasksDir)
|
|
claudeMD, _ := os.ReadFile(filepath.Join(projectRoot, "CLAUDE.md"))
|
|
|
|
prompt := fmt.Sprintf("Kontekst (CLAUDE.md):\n%s\n\nTrenutni taskovi:\n%s\n\nOperater kaže:\n%s",
|
|
string(claudeMD), context, req.Message)
|
|
|
|
// Create chat session
|
|
chatID := nextChatID()
|
|
chat := &chatState{
|
|
id: chatID,
|
|
listeners: make(map[chan string]bool),
|
|
}
|
|
|
|
s.chatMu.Lock()
|
|
s.chats[chatID] = chat
|
|
s.chatMu.Unlock()
|
|
|
|
// Spawn claude CLI process in background
|
|
go s.runChatCommand(chat, prompt)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"chat_id": chatID})
|
|
}
|
|
|
|
// runChatCommand executes claude CLI in a PTY and streams output to chat listeners.
|
|
func (s *Server) runChatCommand(chat *chatState, prompt string) {
|
|
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt)
|
|
cmd.Dir = s.projectRoot()
|
|
cmd.Env = cleanEnv()
|
|
|
|
ptmx, err := startPTY(cmd)
|
|
if err != nil {
|
|
sendChatLine(chat, "[greška pri pokretanju: "+err.Error()+"]")
|
|
finishChat(chat)
|
|
return
|
|
}
|
|
defer ptmx.Close()
|
|
|
|
chat.mu.Lock()
|
|
chat.cmd = cmd
|
|
chat.mu.Unlock()
|
|
|
|
// Read PTY output and send to chat
|
|
readPTY(ptmx, func(line string) {
|
|
sendChatLine(chat, line)
|
|
})
|
|
|
|
cmd.Wait()
|
|
finishChat(chat)
|
|
}
|
|
|
|
// sendChatLine sends a line to all chat listeners and stores in output buffer.
|
|
func sendChatLine(chat *chatState, line string) {
|
|
chat.mu.Lock()
|
|
defer chat.mu.Unlock()
|
|
|
|
chat.output = append(chat.output, line)
|
|
|
|
for ch := range chat.listeners {
|
|
select {
|
|
case ch <- line:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// finishChat marks a chat as done and signals all listeners.
|
|
func finishChat(chat *chatState) {
|
|
chat.mu.Lock()
|
|
defer chat.mu.Unlock()
|
|
|
|
chat.done = true
|
|
chat.cmd = nil
|
|
|
|
for ch := range chat.listeners {
|
|
select {
|
|
case ch <- "[DONE]":
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleChatStream serves an SSE stream for a chat session.
|
|
func (s *Server) handleChatStream(c *gin.Context) {
|
|
chatID := c.Param("id")
|
|
|
|
s.chatMu.RLock()
|
|
chat := s.chats[chatID]
|
|
s.chatMu.RUnlock()
|
|
|
|
if chat == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "chat not found"})
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "text/event-stream")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
|
|
ch := make(chan string, 100)
|
|
|
|
chat.mu.Lock()
|
|
// Replay buffered output
|
|
for _, line := range chat.output {
|
|
fmt.Fprintf(c.Writer, "data: %s\n\n", line)
|
|
}
|
|
c.Writer.Flush()
|
|
|
|
// If already done, send done event and return
|
|
if chat.done {
|
|
chat.mu.Unlock()
|
|
fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n")
|
|
c.Writer.Flush()
|
|
return
|
|
}
|
|
|
|
chat.listeners[ch] = true
|
|
chat.mu.Unlock()
|
|
|
|
defer func() {
|
|
chat.mu.Lock()
|
|
delete(chat.listeners, ch)
|
|
chat.mu.Unlock()
|
|
}()
|
|
|
|
notify := c.Request.Context().Done()
|
|
|
|
for {
|
|
select {
|
|
case <-notify:
|
|
return
|
|
case line := <-ch:
|
|
if line == "[DONE]" {
|
|
fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n")
|
|
c.Writer.Flush()
|
|
return
|
|
}
|
|
fmt.Fprintf(c.Writer, "data: %s\n\n", line)
|
|
c.Writer.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// buildTaskContext creates a text summary of current tasks for the system prompt.
|
|
func buildTaskContext(tasks []supervisor.Task) string {
|
|
var sb strings.Builder
|
|
for _, status := range []string{"backlog", "ready", "active", "review", "done"} {
|
|
sb.WriteString("### " + strings.ToUpper(status) + "\n")
|
|
found := false
|
|
for _, t := range tasks {
|
|
if t.Status == status {
|
|
sb.WriteString(fmt.Sprintf("- %s: %s (%s, %s)\n", t.ID, t.Title, t.Agent, t.Model))
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
sb.WriteString("(prazno)\n")
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
return sb.String()
|
|
}
|