- Razbijen monolitni server_test.go na fokusirane test fajlove: api_test.go, dashboard_test.go, docs_test.go, search_test.go, submit_test.go, task_detail_test.go, console_test.go, sse_test.go, timestamp_test.go, ui_test.go, test_helpers_test.go - Dodat logs.go handler (handleLogsTail) koji je nedostajao - Dodat LogFile u config - Fix konzola: prompt se šalje preko fajla umesto direktno u PTY - 192 testova prolazi Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276 lines
7.3 KiB
Go
276 lines
7.3 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// taskSession represents a PTY session tied to a specific task.
|
|
type taskSession struct {
|
|
TaskID string
|
|
Type string // "work" or "review"
|
|
PTY *consolePTYSession
|
|
Started time.Time
|
|
}
|
|
|
|
// taskSessionResponse is the JSON representation of a task session.
|
|
type taskSessionResponse struct {
|
|
TaskID string `json:"task_id"`
|
|
Type string `json:"type"`
|
|
Status string `json:"status"` // "running" or "exited"
|
|
Started string `json:"started"`
|
|
}
|
|
|
|
// taskSessionManager manages dynamic PTY sessions per task.
|
|
type taskSessionManager struct {
|
|
mu sync.RWMutex
|
|
sessions map[string]*taskSession
|
|
}
|
|
|
|
func newTaskSessionManager() *taskSessionManager {
|
|
return &taskSessionManager{
|
|
sessions: make(map[string]*taskSession),
|
|
}
|
|
}
|
|
|
|
// sessionKey returns a unique key for a task session.
|
|
func sessionKey(taskID, sessionType string) string {
|
|
if sessionType == "review" {
|
|
return taskID + "-review"
|
|
}
|
|
return taskID
|
|
}
|
|
|
|
// startSession spawns an interactive claude PTY and sends the task prompt.
|
|
func (sm *taskSessionManager) startSession(taskID, sessionType, projectDir, prompt string) (*taskSession, error) {
|
|
key := sessionKey(taskID, sessionType)
|
|
|
|
sm.mu.Lock()
|
|
if _, exists := sm.sessions[key]; exists {
|
|
sm.mu.Unlock()
|
|
return nil, fmt.Errorf("sesija %s već postoji", key)
|
|
}
|
|
sm.mu.Unlock()
|
|
|
|
ptySess, err := spawnTaskPTY(projectDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sess := &taskSession{
|
|
TaskID: taskID,
|
|
Type: sessionType,
|
|
PTY: ptySess,
|
|
Started: time.Now(),
|
|
}
|
|
|
|
sm.mu.Lock()
|
|
sm.sessions[key] = sess
|
|
sm.mu.Unlock()
|
|
|
|
log.Printf("Session[%s]: started (PID %d)", key, ptySess.Cmd.Process.Pid)
|
|
|
|
// Write prompt to file, then send a one-liner to claude
|
|
// (multi-line prompt can't be typed into PTY — each \n submits early)
|
|
promptFile := filepath.Join(os.TempDir(), fmt.Sprintf("kaos-%s-prompt.txt", key))
|
|
if err := os.WriteFile(promptFile, []byte(prompt), 0644); err != nil {
|
|
log.Printf("Session[%s]: failed to write prompt file: %v", key, err)
|
|
}
|
|
|
|
go func() {
|
|
subID := fmt.Sprintf("init-%d", time.Now().UnixNano())
|
|
ch := ptySess.Subscribe(subID)
|
|
|
|
timer := time.NewTimer(30 * time.Second)
|
|
select {
|
|
case <-ch:
|
|
// Claude is alive and producing output
|
|
case <-timer.C:
|
|
log.Printf("Session[%s]: timeout waiting for claude to start", key)
|
|
case <-ptySess.Done():
|
|
log.Printf("Session[%s]: claude exited before producing output", key)
|
|
ptySess.Unsubscribe(subID)
|
|
return
|
|
}
|
|
timer.Stop()
|
|
ptySess.Unsubscribe(subID)
|
|
|
|
// Let claude fully render its welcome screen
|
|
time.Sleep(2 * time.Second)
|
|
|
|
// Send one-liner that tells claude to read the prompt file
|
|
oneliner := fmt.Sprintf("Pročitaj fajl %s i uradi SVE što piše unutra.", promptFile)
|
|
log.Printf("Session[%s]: sending prompt via file %s (%d bytes)", key, promptFile, len(prompt))
|
|
ptySess.WriteInput([]byte(oneliner + "\n"))
|
|
}()
|
|
|
|
return sess, nil
|
|
}
|
|
|
|
// getSessionByKey returns a session by its full key.
|
|
func (sm *taskSessionManager) getSessionByKey(key string) *taskSession {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
return sm.sessions[key]
|
|
}
|
|
|
|
// listSessions returns all active sessions.
|
|
func (sm *taskSessionManager) listSessions() []taskSessionResponse {
|
|
sm.mu.RLock()
|
|
defer sm.mu.RUnlock()
|
|
|
|
result := make([]taskSessionResponse, 0, len(sm.sessions))
|
|
for _, sess := range sm.sessions {
|
|
status := "running"
|
|
select {
|
|
case <-sess.PTY.Done():
|
|
status = "exited"
|
|
default:
|
|
}
|
|
|
|
result = append(result, taskSessionResponse{
|
|
TaskID: sess.TaskID,
|
|
Type: sess.Type,
|
|
Status: status,
|
|
Started: sess.Started.Format("15:04:05"),
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
// killSession terminates a session and removes it.
|
|
func (sm *taskSessionManager) killSession(taskID, sessionType string) bool {
|
|
key := sessionKey(taskID, sessionType)
|
|
|
|
sm.mu.Lock()
|
|
sess, exists := sm.sessions[key]
|
|
if exists {
|
|
delete(sm.sessions, key)
|
|
}
|
|
sm.mu.Unlock()
|
|
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
log.Printf("Session[%s]: killed", key)
|
|
sess.PTY.Close()
|
|
return true
|
|
}
|
|
|
|
// handleConsolePage serves the console HTML page.
|
|
func (s *Server) handleConsolePage(c *gin.Context) {
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
c.String(http.StatusOK, renderConsolePage())
|
|
}
|
|
|
|
// handleConsoleSessions returns all active task sessions as JSON.
|
|
func (s *Server) handleConsoleSessions(c *gin.Context) {
|
|
sessions := s.console.listSessions()
|
|
c.JSON(http.StatusOK, sessions)
|
|
}
|
|
|
|
// handleConsoleKill kills a task session.
|
|
func (s *Server) handleConsoleKill(c *gin.Context) {
|
|
taskID := c.Param("taskID")
|
|
sessionType := c.DefaultQuery("type", "work")
|
|
|
|
if s.console.killSession(taskID, sessionType) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "killed", "task": taskID})
|
|
} else {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"})
|
|
}
|
|
}
|
|
|
|
// buildWorkPrompt builds the prompt for a work session.
|
|
func buildWorkPrompt(taskID string, taskContent []byte) string {
|
|
return fmt.Sprintf(`Radiš na tasku %s. Evo sadržaj taska:
|
|
|
|
%s
|
|
|
|
PRAVILA:
|
|
1. Pročitaj agents/coder/CLAUDE.md za pravila kodiranja
|
|
2. Kod piši u code/ folderu
|
|
3. Svi testovi moraju proći: go test ./... -count=1
|
|
4. Build mora proći: go build ./...
|
|
5. go vet ./... mora proći
|
|
6. Commituj sa porukom: %s: Opis na srpskom
|
|
7. Napiši izveštaj u TASKS/reports/%s-report.md
|
|
8. Premesti task fajl: mv TASKS/active/%s.md TASKS/review/%s.md
|
|
9. Kada sve završiš, reci "GOTOVO"`, taskID, string(taskContent), taskID, taskID, taskID, taskID)
|
|
}
|
|
|
|
// buildReviewPrompt builds the prompt for a review session.
|
|
func buildReviewPrompt(taskID string, taskContent, reportContent []byte) string {
|
|
prompt := fmt.Sprintf(`Pregledaj task %s. Evo sadržaj taska:
|
|
|
|
%s
|
|
`, taskID, string(taskContent))
|
|
|
|
if len(reportContent) > 0 {
|
|
prompt += fmt.Sprintf(`
|
|
Izveštaj agenta:
|
|
|
|
%s
|
|
`, string(reportContent))
|
|
}
|
|
|
|
prompt += fmt.Sprintf(`
|
|
KORACI PREGLEDA:
|
|
1. Pročitaj agents/checker/CLAUDE.md za pravila
|
|
2. Proveri da li je kod napisan prema zadatku
|
|
3. Pokreni testove: go test ./... -count=1
|
|
4. Pokreni build: go build ./...
|
|
5. Pokreni vet: go vet ./...
|
|
6. Ako je SVE u redu:
|
|
- Premesti task: mv TASKS/review/%s.md TASKS/done/%s.md
|
|
- Reci "ODOBRENO"
|
|
7. Ako NIJE u redu:
|
|
- Zapiši šta treba popraviti u task fajl
|
|
- Premesti task: mv TASKS/review/%s.md TASKS/active/%s.md
|
|
- Reci "VRAĆENO NA DORADU"`, taskID, taskID, taskID, taskID)
|
|
|
|
return prompt
|
|
}
|
|
|
|
// handleReviewTask launches a review claude session for a task.
|
|
func (s *Server) handleReviewTask(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
// Read task content
|
|
taskPath := filepath.Join(s.Config.TasksDir, "review", id+".md")
|
|
taskContent, err := os.ReadFile(taskPath)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "task nije pronađen u review/"})
|
|
return
|
|
}
|
|
|
|
// Read report if exists
|
|
reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md")
|
|
reportContent, _ := os.ReadFile(reportPath)
|
|
|
|
// Build review prompt
|
|
prompt := buildReviewPrompt(id, taskContent, reportContent)
|
|
|
|
// Start review session
|
|
_, err = s.console.startSession(id, "review", s.projectRoot(), prompt)
|
|
if err != nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "started", "task": id, "type": "review"})
|
|
}
|
|
|
|
// timeNow returns the current time formatted as HH:MM:SS.
|
|
func timeNow() string {
|
|
return time.Now().Format("15:04:05")
|
|
}
|