Konzola: dinamičke task sesije sa PTY per task
- 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>
This commit is contained in:
parent
ac72ca6f52
commit
510b75c0bf
@ -1,385 +1,161 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// sessionState represents the state of a console session.
|
||||
type sessionState struct {
|
||||
mu sync.Mutex
|
||||
status string // "idle" or "running"
|
||||
cmd *exec.Cmd
|
||||
ptySess *consolePTYSession
|
||||
execID string
|
||||
taskID string // which task is being worked on (if any)
|
||||
history []historyEntry
|
||||
output []string
|
||||
listeners map[chan string]bool
|
||||
// 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
|
||||
}
|
||||
|
||||
// historyEntry represents a command in the session history.
|
||||
type historyEntry struct {
|
||||
Command string `json:"command"`
|
||||
ExecID string `json:"exec_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Status string `json:"status"` // "running", "done", "error", "killed"
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// execRequest is the JSON body for starting a command.
|
||||
type execRequest struct {
|
||||
Cmd string `json:"cmd"`
|
||||
Session int `json:"session"`
|
||||
// taskSessionManager manages dynamic PTY sessions per task.
|
||||
type taskSessionManager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*taskSession
|
||||
}
|
||||
|
||||
// execResponse is the JSON response after starting a command.
|
||||
type execResponse struct {
|
||||
ExecID string `json:"exec_id"`
|
||||
Session int `json:"session"`
|
||||
}
|
||||
|
||||
// sessionStatus represents the status of a session for the API.
|
||||
type sessionStatus struct {
|
||||
Session int `json:"session"`
|
||||
Status string `json:"status"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
ExecID string `json:"exec_id,omitempty"`
|
||||
}
|
||||
|
||||
// consoleManager manages the two console sessions.
|
||||
type consoleManager struct {
|
||||
sessions [2]*sessionState
|
||||
mu sync.Mutex
|
||||
counter int
|
||||
}
|
||||
|
||||
// newConsoleManager creates a new console manager with two idle sessions.
|
||||
func newConsoleManager() *consoleManager {
|
||||
return &consoleManager{
|
||||
sessions: [2]*sessionState{
|
||||
{status: "idle", listeners: make(map[chan string]bool)},
|
||||
{status: "idle", listeners: make(map[chan string]bool)},
|
||||
},
|
||||
func newTaskSessionManager() *taskSessionManager {
|
||||
return &taskSessionManager{
|
||||
sessions: make(map[string]*taskSession),
|
||||
}
|
||||
}
|
||||
|
||||
// nextExecID generates a unique execution ID.
|
||||
func (cm *consoleManager) nextExecID() string {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
cm.counter++
|
||||
return fmt.Sprintf("exec-%d-%d", time.Now().Unix(), cm.counter)
|
||||
// sessionKey returns a unique key for a task session.
|
||||
func sessionKey(taskID, sessionType string) string {
|
||||
if sessionType == "review" {
|
||||
return taskID + "-review"
|
||||
}
|
||||
return taskID
|
||||
}
|
||||
|
||||
// getSession returns a session by index (0 or 1).
|
||||
func (cm *consoleManager) getSession(idx int) *sessionState {
|
||||
if idx < 0 || idx > 1 {
|
||||
return nil
|
||||
// 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)
|
||||
}
|
||||
return cm.sessions[idx]
|
||||
}
|
||||
sm.mu.Unlock()
|
||||
|
||||
// handleConsoleExec starts a command in a session.
|
||||
func (s *Server) handleConsoleExec(c *gin.Context) {
|
||||
var req execRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidan JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Session < 1 || req.Session > 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "sesija mora biti 1 ili 2"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionIdx := req.Session - 1
|
||||
session := s.console.getSession(sessionIdx)
|
||||
|
||||
session.mu.Lock()
|
||||
if session.status == "running" {
|
||||
session.mu.Unlock()
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "sesija je zauzeta"})
|
||||
return
|
||||
}
|
||||
|
||||
execID := s.console.nextExecID()
|
||||
session.status = "running"
|
||||
session.execID = execID
|
||||
session.output = nil
|
||||
session.mu.Unlock()
|
||||
|
||||
// Add to history
|
||||
entry := historyEntry{
|
||||
Command: req.Cmd,
|
||||
ExecID: execID,
|
||||
Timestamp: timeNow(),
|
||||
Status: "running",
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
session.history = append(session.history, entry)
|
||||
if len(session.history) > 50 {
|
||||
session.history = session.history[len(session.history)-50:]
|
||||
}
|
||||
session.mu.Unlock()
|
||||
|
||||
// Start the command in background
|
||||
go s.runCommand(session, req.Cmd, execID)
|
||||
|
||||
c.JSON(http.StatusOK, execResponse{
|
||||
ExecID: execID,
|
||||
Session: req.Session,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// runCommand spawns a PTY-backed claude CLI process and monitors it.
|
||||
func (s *Server) runCommand(session *sessionState, command, execID string) {
|
||||
ptySess, err := spawnConsolePTY(s.projectRoot(), command)
|
||||
ptySess, err := spawnTaskPTY(projectDir)
|
||||
if err != nil {
|
||||
log.Printf("PTY spawn error for %s: %v", execID, err)
|
||||
s.finishSession(session, execID, "error")
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("PTY spawned for %s (PID %d)", execID, ptySess.Cmd.Process.Pid)
|
||||
|
||||
session.mu.Lock()
|
||||
session.cmd = ptySess.Cmd
|
||||
session.ptySess = ptySess
|
||||
session.mu.Unlock()
|
||||
|
||||
// Wait for process to exit
|
||||
<-ptySess.Done()
|
||||
|
||||
status := "done"
|
||||
if ptySess.Cmd.ProcessState != nil && !ptySess.Cmd.ProcessState.Success() {
|
||||
status = "error"
|
||||
sess := &taskSession{
|
||||
TaskID: taskID,
|
||||
Type: sessionType,
|
||||
PTY: ptySess,
|
||||
Started: time.Now(),
|
||||
}
|
||||
log.Printf("PTY finished for %s (status: %s)", execID, status)
|
||||
|
||||
// Note: we do NOT clear session.ptySess here — the WS handler
|
||||
// needs it for replay buffer even after the process exits.
|
||||
// It gets replaced when a new command starts.
|
||||
sm.mu.Lock()
|
||||
sm.sessions[key] = sess
|
||||
sm.mu.Unlock()
|
||||
|
||||
s.finishSession(session, execID, status)
|
||||
}
|
||||
log.Printf("Session[%s]: started (PID %d)", key, ptySess.Cmd.Process.Pid)
|
||||
|
||||
// sendToSession sends a line to all listeners and stores in output buffer.
|
||||
func (s *Server) sendToSession(session *sessionState, line string) {
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
// Send the task prompt after claude initializes
|
||||
go func() {
|
||||
subID := fmt.Sprintf("init-%d", time.Now().UnixNano())
|
||||
ch := ptySess.Subscribe(subID)
|
||||
|
||||
session.output = append(session.output, line)
|
||||
|
||||
for ch := range session.listeners {
|
||||
timer := time.NewTimer(30 * time.Second)
|
||||
select {
|
||||
case ch <- line:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// finishSession marks a session as idle and notifies listeners.
|
||||
func (s *Server) finishSession(session *sessionState, execID, status string) {
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
|
||||
session.status = "idle"
|
||||
session.cmd = nil
|
||||
|
||||
// Update history entry status
|
||||
for i := len(session.history) - 1; i >= 0; i-- {
|
||||
if session.history[i].ExecID == execID {
|
||||
session.history[i].Status = status
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Notify listeners that stream is done
|
||||
for ch := range session.listeners {
|
||||
select {
|
||||
case ch <- "[DONE]":
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleConsoleStream serves an SSE stream for a command execution.
|
||||
func (s *Server) handleConsoleStream(c *gin.Context) {
|
||||
execID := c.Param("id")
|
||||
|
||||
// Find which session has this exec ID
|
||||
var session *sessionState
|
||||
for i := 0; i < 2; i++ {
|
||||
sess := s.console.getSession(i)
|
||||
sess.mu.Lock()
|
||||
if sess.execID == execID {
|
||||
session = sess
|
||||
sess.mu.Unlock()
|
||||
break
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
}
|
||||
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"})
|
||||
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)
|
||||
|
||||
// Set SSE headers
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
// Let claude fully render its welcome screen
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Create listener channel
|
||||
ch := make(chan string, 100)
|
||||
|
||||
session.mu.Lock()
|
||||
// Send buffered output first
|
||||
for _, line := range session.output {
|
||||
fmt.Fprintf(c.Writer, "data: %s\n\n", line)
|
||||
}
|
||||
c.Writer.Flush()
|
||||
|
||||
// If already done, send done event and return
|
||||
if session.status == "idle" && session.execID == execID {
|
||||
session.mu.Unlock()
|
||||
fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n")
|
||||
c.Writer.Flush()
|
||||
return
|
||||
}
|
||||
|
||||
session.listeners[ch] = true
|
||||
session.mu.Unlock()
|
||||
|
||||
// Clean up on disconnect
|
||||
defer func() {
|
||||
session.mu.Lock()
|
||||
delete(session.listeners, ch)
|
||||
session.mu.Unlock()
|
||||
// Type the prompt
|
||||
log.Printf("Session[%s]: sending prompt (%d bytes)", key, len(prompt))
|
||||
ptySess.WriteInput([]byte(prompt + "\n"))
|
||||
}()
|
||||
|
||||
notify := c.Request.Context().Done()
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
for {
|
||||
// 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 <-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()
|
||||
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
|
||||
}
|
||||
|
||||
// handleConsoleKill kills the running process in a session.
|
||||
func (s *Server) handleConsoleKill(c *gin.Context) {
|
||||
sessionNum, err := strconv.Atoi(c.Param("session"))
|
||||
if err != nil || sessionNum < 1 || sessionNum > 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidna sesija"})
|
||||
return
|
||||
// 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
|
||||
}
|
||||
|
||||
session := s.console.getSession(sessionNum - 1)
|
||||
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
|
||||
if session.status != "running" {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "idle", "message": "sesija nije aktivna"})
|
||||
return
|
||||
}
|
||||
|
||||
// Close PTY session if it exists
|
||||
if session.ptySess != nil {
|
||||
session.ptySess.Close()
|
||||
session.ptySess = nil
|
||||
} else if session.cmd != nil && session.cmd.Process != nil {
|
||||
session.cmd.Process.Kill()
|
||||
}
|
||||
|
||||
// Update history
|
||||
for i := len(session.history) - 1; i >= 0; i-- {
|
||||
if session.history[i].ExecID == session.execID {
|
||||
session.history[i].Status = "killed"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
session.status = "idle"
|
||||
session.cmd = nil
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "killed"})
|
||||
}
|
||||
|
||||
// handleConsoleSessions returns the status of both sessions.
|
||||
func (s *Server) handleConsoleSessions(c *gin.Context) {
|
||||
statuses := make([]sessionStatus, 2)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
sess := s.console.getSession(i)
|
||||
sess.mu.Lock()
|
||||
statuses[i] = sessionStatus{
|
||||
Session: i + 1,
|
||||
Status: sess.status,
|
||||
TaskID: sess.taskID,
|
||||
ExecID: sess.execID,
|
||||
}
|
||||
sess.mu.Unlock()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, statuses)
|
||||
}
|
||||
|
||||
// handleConsoleHistory returns command history for a session.
|
||||
func (s *Server) handleConsoleHistory(c *gin.Context) {
|
||||
sessionNum, err := strconv.Atoi(c.Param("session"))
|
||||
if err != nil || sessionNum < 1 || sessionNum > 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidna sesija"})
|
||||
return
|
||||
}
|
||||
|
||||
session := s.console.getSession(sessionNum - 1)
|
||||
|
||||
session.mu.Lock()
|
||||
history := make([]historyEntry, len(session.history))
|
||||
copy(history, session.history)
|
||||
session.mu.Unlock()
|
||||
|
||||
data, _ := json.Marshal(history)
|
||||
c.Header("Content-Type", "application/json")
|
||||
c.String(http.StatusOK, string(data))
|
||||
}
|
||||
|
||||
// timeNow returns the current time formatted as HH:MM:SS.
|
||||
func timeNow() string {
|
||||
return time.Now().Format("15:04:05")
|
||||
log.Printf("Session[%s]: killed", key)
|
||||
sess.PTY.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// handleConsolePage serves the console HTML page.
|
||||
@ -387,3 +163,106 @@ 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")
|
||||
}
|
||||
|
||||
@ -79,19 +79,20 @@ type consolePTYSession struct {
|
||||
lastActive time.Time
|
||||
}
|
||||
|
||||
// spawnConsolePTY starts a new claude CLI in a PTY for the console.
|
||||
func spawnConsolePTY(projectDir, prompt string) (*consolePTYSession, error) {
|
||||
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt)
|
||||
// spawnTaskPTY starts an interactive claude CLI with auto-permissions in a PTY.
|
||||
// Used for task work and review sessions. The prompt is sent after startup.
|
||||
func spawnTaskPTY(projectDir string) (*consolePTYSession, error) {
|
||||
cmd := exec.Command("claude", "--permission-mode", "dontAsk")
|
||||
cmd.Dir = projectDir
|
||||
cmd.Env = cleanEnvForPTY()
|
||||
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 120})
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 50, Cols: 180})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start pty: %w", err)
|
||||
}
|
||||
|
||||
sess := &consolePTYSession{
|
||||
ID: fmt.Sprintf("pty-%d", time.Now().UnixNano()),
|
||||
ID: fmt.Sprintf("task-%d", time.Now().UnixNano()),
|
||||
Ptmx: ptmx,
|
||||
Cmd: cmd,
|
||||
buffer: NewRingBuffer(outputBufferSize),
|
||||
|
||||
@ -4,6 +4,7 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -22,7 +23,7 @@ import (
|
||||
type Server struct {
|
||||
Config *config.Config
|
||||
Router *gin.Engine
|
||||
console *consoleManager
|
||||
console *taskSessionManager
|
||||
events *eventBroker
|
||||
chatMu sync.RWMutex
|
||||
chats map[string]*chatState
|
||||
@ -73,7 +74,7 @@ func New(cfg *config.Config) *Server {
|
||||
s := &Server{
|
||||
Config: cfg,
|
||||
Router: router,
|
||||
console: newConsoleManager(),
|
||||
console: newTaskSessionManager(),
|
||||
events: newEventBroker(cfg.TasksDir),
|
||||
chats: make(map[string]*chatState),
|
||||
}
|
||||
@ -106,6 +107,7 @@ func (s *Server) setupRoutes() {
|
||||
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.POST("/task/:id/review", s.handleReviewTask)
|
||||
s.Router.GET("/report/:id", s.handleReport)
|
||||
|
||||
// SSE events
|
||||
@ -116,12 +118,9 @@ func (s *Server) setupRoutes() {
|
||||
|
||||
// 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)
|
||||
s.Router.POST("/console/kill/:taskID", s.handleConsoleKill)
|
||||
s.Router.GET("/console/ws/:key", s.handleConsoleWS)
|
||||
|
||||
// Logs route
|
||||
s.Router.GET("/api/logs/tail", s.handleLogsTail)
|
||||
@ -359,9 +358,19 @@ func (s *Server) handleRunTask(c *gin.Context) {
|
||||
taskPath := filepath.Join(s.Config.TasksDir, "active", id+".md")
|
||||
appendTimestamp(taskPath, "Pokrenut (→active)")
|
||||
|
||||
// Read task content and spawn a claude work session
|
||||
taskContent, _ := os.ReadFile(taskPath)
|
||||
prompt := buildWorkPrompt(id, taskContent)
|
||||
|
||||
_, err = s.console.startSession(id, "work", s.projectRoot(), prompt)
|
||||
if err != nil {
|
||||
log.Printf("Warning: session start failed for %s: %v", id, err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "started",
|
||||
"task": id,
|
||||
"session": id,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -1098,15 +1098,15 @@ func TestConsolePage(t *testing.T) {
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !containsStr(body, "Sesija 1") {
|
||||
t.Error("expected 'Sesija 1' in console page")
|
||||
if !containsStr(body, "KAOS") {
|
||||
t.Error("expected 'KAOS' in console page")
|
||||
}
|
||||
if !containsStr(body, "Sesija 2") {
|
||||
t.Error("expected 'Sesija 2' in console page")
|
||||
if !containsStr(body, "Konzola") {
|
||||
t.Error("expected 'Konzola' in console page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleSessions(t *testing.T) {
|
||||
func TestConsoleSessions_Empty(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/console/sessions", nil)
|
||||
@ -1117,115 +1117,25 @@ func TestConsoleSessions(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var statuses []sessionStatus
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &statuses); err != nil {
|
||||
var sessions []taskSessionResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &sessions); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if len(statuses) != 2 {
|
||||
t.Fatalf("expected 2 sessions, got %d", len(statuses))
|
||||
}
|
||||
if statuses[0].Status != "idle" || statuses[1].Status != "idle" {
|
||||
t.Error("expected both sessions idle")
|
||||
if len(sessions) != 0 {
|
||||
t.Fatalf("expected 0 sessions, got %d", len(sessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleExec_InvalidSession(t *testing.T) {
|
||||
func TestConsoleKill_NotFound(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
body := `{"cmd":"status","session":3}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req := httptest.NewRequest(http.MethodPost, "/console/kill/T99", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid session, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleExec_ValidRequest(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
body := `{"cmd":"echo test","session":1}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp execResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if resp.ExecID == "" {
|
||||
t.Error("expected non-empty exec ID")
|
||||
}
|
||||
if resp.Session != 1 {
|
||||
t.Errorf("expected session 1, got %d", resp.Session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleKill_IdleSession(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/console/kill/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleHistory_Empty(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/console/history/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var history []historyEntry
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &history); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if len(history) != 0 {
|
||||
t.Errorf("expected empty history, got %d entries", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleHistory_AfterExec(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
// Execute a command first
|
||||
body := `{"cmd":"test command","session":2}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
// Check history
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/console/history/2", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w2, req2)
|
||||
|
||||
var history []historyEntry
|
||||
json.Unmarshal(w2.Body.Bytes(), &history)
|
||||
|
||||
if len(history) != 1 {
|
||||
t.Fatalf("expected 1 history entry, got %d", len(history))
|
||||
}
|
||||
if history[0].Command != "test command" {
|
||||
t.Errorf("expected 'test command', got %s", history[0].Command)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1635,7 +1545,7 @@ func TestConsolePage_ToolbarAbovePanels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolePage_HasSessionToggle(t *testing.T) {
|
||||
func TestConsolePage_HasDynamicSessions(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||
@ -1643,11 +1553,11 @@ func TestConsolePage_HasSessionToggle(t *testing.T) {
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !containsStr(body, "togglePanel2") {
|
||||
t.Error("expected togglePanel2 button in console page")
|
||||
if !containsStr(body, "refreshSessions") {
|
||||
t.Error("expected refreshSessions function in console page")
|
||||
}
|
||||
if !containsStr(body, `+ Sesija 2`) {
|
||||
t.Error("expected '+ Sesija 2' toggle button")
|
||||
if !containsStr(body, "/console/sessions") {
|
||||
t.Error("expected /console/sessions API call")
|
||||
}
|
||||
}
|
||||
|
||||
@ -2070,15 +1980,15 @@ func TestConsolePage_HasWebSocket(t *testing.T) {
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !containsStr(body, "/console/ws/") {
|
||||
t.Error("expected WebSocket URL /console/ws/ in console page")
|
||||
if !containsStr(body, "console/ws/") {
|
||||
t.Error("expected WebSocket URL console/ws/ in console page")
|
||||
}
|
||||
if !containsStr(body, "new WebSocket") {
|
||||
t.Error("expected WebSocket constructor in console page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolePage_HasTerminalContainers(t *testing.T) {
|
||||
func TestConsolePage_HasEmptyState(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||
@ -2086,14 +1996,14 @@ func TestConsolePage_HasTerminalContainers(t *testing.T) {
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !containsStr(body, `id="terminal-1"`) {
|
||||
t.Error("expected terminal-1 container")
|
||||
if !containsStr(body, "empty-state") {
|
||||
t.Error("expected empty-state element")
|
||||
}
|
||||
if !containsStr(body, `id="terminal-2"`) {
|
||||
t.Error("expected terminal-2 container")
|
||||
if !containsStr(body, "Pusti") {
|
||||
t.Error("expected 'Pusti' instruction in empty state")
|
||||
}
|
||||
if !containsStr(body, "console-terminal") {
|
||||
t.Error("expected console-terminal class")
|
||||
t.Error("expected console-terminal class in JS code")
|
||||
}
|
||||
}
|
||||
|
||||
@ -2128,3 +2038,128 @@ func TestConsolePage_HasResizeHandler(t *testing.T) {
|
||||
t.Error("expected resize message type in WebSocket handler")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task session manager tests ──────────────────────
|
||||
|
||||
func TestSessionKey(t *testing.T) {
|
||||
if got := sessionKey("T01", "work"); got != "T01" {
|
||||
t.Errorf("expected T01, got %s", got)
|
||||
}
|
||||
if got := sessionKey("T01", "review"); got != "T01-review" {
|
||||
t.Errorf("expected T01-review, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSessionManager_ListEmpty(t *testing.T) {
|
||||
sm := newTaskSessionManager()
|
||||
sessions := sm.listSessions()
|
||||
if len(sessions) != 0 {
|
||||
t.Errorf("expected 0 sessions, got %d", len(sessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSessionManager_KillNotFound(t *testing.T) {
|
||||
sm := newTaskSessionManager()
|
||||
if sm.killSession("T99", "work") {
|
||||
t.Error("expected false for non-existent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSessionManager_GetNotFound(t *testing.T) {
|
||||
sm := newTaskSessionManager()
|
||||
if sm.getSessionByKey("T99") != nil {
|
||||
t.Error("expected nil for non-existent session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkPrompt(t *testing.T) {
|
||||
prompt := buildWorkPrompt("T08", []byte("# T08: Test task\n\nOpis."))
|
||||
|
||||
if !containsStr(prompt, "T08") {
|
||||
t.Error("expected task ID in prompt")
|
||||
}
|
||||
if !containsStr(prompt, "Test task") {
|
||||
t.Error("expected task content in prompt")
|
||||
}
|
||||
if !containsStr(prompt, "agents/coder/CLAUDE.md") {
|
||||
t.Error("expected coder CLAUDE.md reference")
|
||||
}
|
||||
if !containsStr(prompt, "go test") {
|
||||
t.Error("expected test instruction")
|
||||
}
|
||||
if !containsStr(prompt, "report") {
|
||||
t.Error("expected report instruction")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReviewPrompt(t *testing.T) {
|
||||
prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), []byte("# Report\nSve ok."))
|
||||
|
||||
if !containsStr(prompt, "T08") {
|
||||
t.Error("expected task ID in review prompt")
|
||||
}
|
||||
if !containsStr(prompt, "Sve ok") {
|
||||
t.Error("expected report content in review prompt")
|
||||
}
|
||||
if !containsStr(prompt, "agents/checker/CLAUDE.md") {
|
||||
t.Error("expected checker CLAUDE.md reference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReviewPrompt_NoReport(t *testing.T) {
|
||||
prompt := buildReviewPrompt("T08", []byte("# T08: Test\n"), nil)
|
||||
|
||||
if !containsStr(prompt, "T08") {
|
||||
t.Error("expected task ID")
|
||||
}
|
||||
if containsStr(prompt, "Izveštaj agenta") {
|
||||
t.Error("should not include report section when no report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewTask_NotFound(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/task/T99/review", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsolePage_HasKillButton(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !containsStr(body, "killSession") {
|
||||
t.Error("expected killSession function in console page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetail_HasProveriButton(t *testing.T) {
|
||||
srv := setupTestServer(t)
|
||||
|
||||
// Put T08 in review
|
||||
os.Rename(
|
||||
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
|
||||
filepath.Join(srv.Config.TasksDir, "review", "T08.md"),
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/task/T08", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Router.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
if !containsStr(body, "Proveri") {
|
||||
t.Error("expected 'Proveri' button for review task")
|
||||
}
|
||||
if !containsStr(body, "/review") {
|
||||
t.Error("expected /review endpoint in Proveri button")
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -19,6 +20,26 @@ import (
|
||||
"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
|
||||
@ -136,7 +157,7 @@ func (s *Server) handleChatSubmit(c *gin.Context) {
|
||||
string(claudeMD), context, req.Message)
|
||||
|
||||
// Create chat session
|
||||
chatID := s.console.nextExecID()
|
||||
chatID := nextChatID()
|
||||
chat := &chatState{
|
||||
id: chatID,
|
||||
listeners: make(map[chan string]bool),
|
||||
|
||||
@ -2,10 +2,8 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
@ -24,37 +22,40 @@ type wsResizeMsg struct {
|
||||
Rows uint16 `json:"rows"`
|
||||
}
|
||||
|
||||
// handleConsoleWS handles WebSocket connections for console terminals.
|
||||
// Each connection gets its own independent shell PTY session.
|
||||
// handleConsoleWS handles WebSocket connections for task console terminals.
|
||||
// Each connection attaches to an existing PTY session by task key.
|
||||
func (s *Server) handleConsoleWS(c *gin.Context) {
|
||||
sessionNum := c.Param("session")
|
||||
if sessionNum != "1" && sessionNum != "2" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "sesija mora biti 1 ili 2"})
|
||||
key := c.Param("key") // e.g., "T08" or "T08-review"
|
||||
|
||||
sess := s.console.getSessionByKey(key)
|
||||
if sess == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"})
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade error: %v", err)
|
||||
log.Printf("WS[%s]: upgrade error: %v", key, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
log.Printf("WS[%s]: connected, spawning shell", sessionNum)
|
||||
log.Printf("WS[%s]: connected", key)
|
||||
|
||||
// Spawn a fresh interactive shell for this connection
|
||||
ptySess, err := spawnShellPTY(s.projectRoot())
|
||||
if err != nil {
|
||||
log.Printf("WS[%s]: shell spawn error: %v", sessionNum, err)
|
||||
conn.WriteMessage(websocket.TextMessage,
|
||||
[]byte(fmt.Sprintf("\r\n\033[31m[Greška: %v]\033[0m\r\n", err)))
|
||||
ptySess := sess.PTY
|
||||
|
||||
// Send replay buffer so the user sees existing output
|
||||
replay := ptySess.GetBuffer()
|
||||
if len(replay) > 0 {
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, replay); err != nil {
|
||||
log.Printf("WS[%s]: replay write error: %v", key, err)
|
||||
return
|
||||
}
|
||||
defer ptySess.Close()
|
||||
log.Printf("WS[%s]: replayed %d bytes", key, len(replay))
|
||||
}
|
||||
|
||||
log.Printf("WS[%s]: shell started (PID %d)", sessionNum, ptySess.Cmd.Process.Pid)
|
||||
|
||||
subID := fmt.Sprintf("ws-%d", time.Now().UnixNano())
|
||||
// Subscribe to new PTY output
|
||||
subID := key + "-ws"
|
||||
outputCh := ptySess.Subscribe(subID)
|
||||
defer ptySess.Unsubscribe(subID)
|
||||
|
||||
@ -87,7 +88,7 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
|
||||
go func() {
|
||||
select {
|
||||
case <-ptySess.Done():
|
||||
log.Printf("WS[%s]: shell exited", sessionNum)
|
||||
log.Printf("WS[%s]: process exited", key)
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"))
|
||||
case <-stopCh:
|
||||
@ -99,7 +100,7 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
log.Printf("WS[%s]: read error: %v", sessionNum, err)
|
||||
log.Printf("WS[%s]: read error: %v", key, err)
|
||||
}
|
||||
break
|
||||
}
|
||||
@ -108,14 +109,14 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
|
||||
var resize wsResizeMsg
|
||||
if json.Unmarshal(msg, &resize) == nil && resize.Type == "resize" && resize.Cols > 0 && resize.Rows > 0 {
|
||||
if err := ptySess.Resize(resize.Rows, resize.Cols); err != nil {
|
||||
log.Printf("WS[%s]: resize error: %v", sessionNum, err)
|
||||
log.Printf("WS[%s]: resize error: %v", key, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular keyboard input → PTY
|
||||
if _, err := ptySess.WriteInput(msg); err != nil {
|
||||
log.Printf("WS[%s]: write error: %v", sessionNum, err)
|
||||
log.Printf("WS[%s]: write error: %v", key, err)
|
||||
break
|
||||
}
|
||||
}
|
||||
@ -123,5 +124,5 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
|
||||
close(stopCh)
|
||||
close(writeCh)
|
||||
<-writeDone
|
||||
log.Printf("WS[%s]: disconnected", sessionNum)
|
||||
log.Printf("WS[%s]: disconnected", key)
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🔧 KAOS Dashboard</h1>
|
||||
<h1>KAOS Dashboard</h1>
|
||||
<div class="header-right">
|
||||
<div class="theme-toggle">
|
||||
<button class="theme-btn" data-theme-mode="light" onclick="setTheme('light')" title="Svetla tema">☀️</button>
|
||||
@ -31,24 +31,11 @@
|
||||
|
||||
<div class="console-container">
|
||||
<div class="console-toolbar">
|
||||
<button class="btn" id="toggle-panel" onclick="togglePanel2()">+ Sesija 2</button>
|
||||
<span id="session-info">Sesije: 0</span>
|
||||
</div>
|
||||
|
||||
<div class="console-panels">
|
||||
<div class="console-panel" id="panel-1">
|
||||
<div class="console-panel-header">
|
||||
<span>🔧 Sesija 1</span>
|
||||
<span class="session-status session-running" id="status-1">connected</span>
|
||||
</div>
|
||||
<div class="console-terminal" id="terminal-1"></div>
|
||||
</div>
|
||||
|
||||
<div class="console-panel" id="panel-2" style="display:none">
|
||||
<div class="console-panel-header">
|
||||
<span>🔧 Sesija 2</span>
|
||||
<span class="session-status" id="status-2">idle</span>
|
||||
</div>
|
||||
<div class="console-terminal" id="terminal-2"></div>
|
||||
<div class="console-panels" id="panels">
|
||||
<div class="console-empty" id="empty-state">
|
||||
<p>Nema aktivnih sesija. Kliknite "Pusti" na tasku da pokrenete rad.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -83,13 +70,38 @@ function getTermTheme() {
|
||||
}
|
||||
|
||||
// ── Session state ────────────────────────────────────
|
||||
var sessions = [{}, {}];
|
||||
var terminals = {};
|
||||
|
||||
function sessionKey(sess) {
|
||||
return sess.type === 'review' ? sess.task_id + '-review' : sess.task_id;
|
||||
}
|
||||
|
||||
function createTerminal(sess) {
|
||||
var key = sessionKey(sess);
|
||||
if (terminals[key]) return;
|
||||
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
|
||||
var panel = document.createElement('div');
|
||||
panel.className = 'console-panel';
|
||||
panel.id = 'panel-' + key;
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'console-panel-header';
|
||||
var label = sess.task_id + (sess.type === 'review' ? ' [pregled]' : ' [rad]');
|
||||
header.innerHTML = '<span>' + label + '</span>' +
|
||||
'<span class="session-status session-running" id="status-' + key + '">' + sess.status + '</span>' +
|
||||
'<button class="btn btn-sm" onclick="killSession(\'' + sess.task_id + '\', \'' + sess.type + '\')">Ugasi</button>';
|
||||
|
||||
var termDiv = document.createElement('div');
|
||||
termDiv.className = 'console-terminal';
|
||||
termDiv.id = 'terminal-' + key;
|
||||
|
||||
panel.appendChild(header);
|
||||
panel.appendChild(termDiv);
|
||||
document.getElementById('panels').appendChild(panel);
|
||||
|
||||
function initTerminal(idx) {
|
||||
var num = idx + 1;
|
||||
var containerEl = document.getElementById('terminal-' + num);
|
||||
var theme = getTermTheme();
|
||||
|
||||
var term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
@ -106,41 +118,22 @@ function initTerminal(idx) {
|
||||
var fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon.WebLinksAddon());
|
||||
term.open(containerEl);
|
||||
term.open(termDiv);
|
||||
|
||||
// Keyboard input → WebSocket
|
||||
term.onData(function(data) {
|
||||
var ws = sessions[idx].ws;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
terminals[key] = { term: term, fitAddon: fitAddon, ws: null };
|
||||
|
||||
// Resize → WebSocket
|
||||
term.onResize(function(size) {
|
||||
var ws = sessions[idx].ws;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
|
||||
}
|
||||
});
|
||||
|
||||
containerEl.addEventListener('click', function() { term.focus(); });
|
||||
|
||||
sessions[idx].term = term;
|
||||
sessions[idx].fitAddon = fitAddon;
|
||||
sessions[idx].ws = null;
|
||||
termDiv.addEventListener('click', function() { term.focus(); });
|
||||
|
||||
setTimeout(function() {
|
||||
fitAddon.fit();
|
||||
// Auto-connect WebSocket immediately
|
||||
connectWS(idx);
|
||||
connectWS(key, term);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// ── WebSocket connection ─────────────────────────────
|
||||
function connectWS(idx) {
|
||||
var num = idx + 1;
|
||||
var sess = sessions[idx];
|
||||
function connectWS(key, term) {
|
||||
var sess = terminals[key];
|
||||
if (!sess) return;
|
||||
|
||||
if (sess.ws) {
|
||||
sess.ws.close();
|
||||
@ -148,62 +141,95 @@ function connectWS(idx) {
|
||||
}
|
||||
|
||||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
var url = proto + '//' + location.host + '/console/ws/' + num;
|
||||
var url = proto + '//' + location.host + '/console/ws/' + key;
|
||||
var ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = function() {
|
||||
setSessionUI(num, 'connected');
|
||||
var term = sess.term;
|
||||
var el = document.getElementById('status-' + key);
|
||||
if (el) { el.textContent = 'connected'; el.className = 'session-status session-running'; }
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
term.focus();
|
||||
};
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
sess.term.write(new Uint8Array(event.data));
|
||||
term.write(new Uint8Array(event.data));
|
||||
} else {
|
||||
sess.term.write(event.data);
|
||||
term.write(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function() {
|
||||
sess.ws = null;
|
||||
setSessionUI(num, 'disconnected');
|
||||
var el = document.getElementById('status-' + key);
|
||||
if (el) { el.textContent = 'disconnected'; el.className = 'session-status'; }
|
||||
};
|
||||
|
||||
ws.onerror = function() {
|
||||
sess.ws = null;
|
||||
setSessionUI(num, 'error');
|
||||
};
|
||||
|
||||
// Keyboard input → WebSocket
|
||||
term.onData(function(data) {
|
||||
if (sess.ws && sess.ws.readyState === WebSocket.OPEN) {
|
||||
sess.ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Resize → WebSocket
|
||||
term.onResize(function(size) {
|
||||
if (sess.ws && sess.ws.readyState === WebSocket.OPEN) {
|
||||
sess.ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
|
||||
}
|
||||
});
|
||||
|
||||
sess.ws = ws;
|
||||
}
|
||||
|
||||
// ── UI helpers ───────────────────────────────────────
|
||||
function setSessionUI(session, status) {
|
||||
var el = document.getElementById('status-' + session);
|
||||
el.textContent = status;
|
||||
el.className = 'session-status';
|
||||
if (status === 'connected') el.className += ' session-running';
|
||||
// ── Session management ───────────────────────────────
|
||||
function killSession(taskID, type) {
|
||||
fetch('/console/kill/' + taskID + '?type=' + type, { method: 'POST' })
|
||||
.then(function(resp) { return resp.json(); })
|
||||
.then(function() { refreshSessions(); });
|
||||
}
|
||||
|
||||
function togglePanel2() {
|
||||
var panel = document.getElementById('panel-2');
|
||||
var btn = document.getElementById('toggle-panel');
|
||||
if (panel.style.display === 'none') {
|
||||
panel.style.display = 'flex';
|
||||
btn.textContent = '- Sesija 2';
|
||||
if (!sessions[1].term) {
|
||||
initTerminal(1);
|
||||
function refreshSessions() {
|
||||
fetch('/console/sessions')
|
||||
.then(function(resp) { return resp.json(); })
|
||||
.then(function(sessions) {
|
||||
var info = document.getElementById('session-info');
|
||||
info.textContent = 'Sesije: ' + sessions.length;
|
||||
|
||||
if (sessions.length === 0) {
|
||||
document.getElementById('empty-state').style.display = 'block';
|
||||
} else {
|
||||
sessions[1].fitAddon.fit();
|
||||
if (!sessions[1].ws) connectWS(1);
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
btn.textContent = '+ Sesija 2';
|
||||
|
||||
var currentKeys = {};
|
||||
sessions.forEach(function(sess) {
|
||||
var key = sessionKey(sess);
|
||||
currentKeys[key] = true;
|
||||
createTerminal(sess);
|
||||
|
||||
var el = document.getElementById('status-' + key);
|
||||
if (el && sess.status === 'exited') {
|
||||
el.textContent = 'finished';
|
||||
el.className = 'session-status session-done';
|
||||
}
|
||||
});
|
||||
|
||||
// Remove panels for sessions that no longer exist
|
||||
Object.keys(terminals).forEach(function(key) {
|
||||
if (!currentKeys[key]) {
|
||||
var panel = document.getElementById('panel-' + key);
|
||||
if (panel) panel.remove();
|
||||
if (terminals[key].ws) terminals[key].ws.close();
|
||||
delete terminals[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Theme sync ───────────────────────────────────────
|
||||
@ -212,25 +238,26 @@ window.setTheme = function(mode) {
|
||||
if (origSetTheme) origSetTheme(mode);
|
||||
setTimeout(function() {
|
||||
var theme = getTermTheme();
|
||||
for (var i = 0; i < 2; i++) {
|
||||
if (sessions[i].term) {
|
||||
sessions[i].term.options.theme = theme;
|
||||
}
|
||||
Object.keys(terminals).forEach(function(key) {
|
||||
if (terminals[key].term) {
|
||||
terminals[key].term.options.theme = theme;
|
||||
}
|
||||
});
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// ── Window resize ────────────────────────────────────
|
||||
window.addEventListener('resize', function() {
|
||||
for (var i = 0; i < 2; i++) {
|
||||
if (sessions[i].fitAddon) {
|
||||
sessions[i].fitAddon.fit();
|
||||
}
|
||||
Object.keys(terminals).forEach(function(key) {
|
||||
if (terminals[key].fitAddon) {
|
||||
terminals[key].fitAddon.fit();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Initialize ───────────────────────────────────────
|
||||
initTerminal(0);
|
||||
refreshSessions();
|
||||
setInterval(refreshSessions, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
<a href="/docs" class="btn">Dokumenti</a>
|
||||
<a href="/console" class="btn">Konzola</a>
|
||||
<a href="/submit" class="btn">Prijava</a>
|
||||
<a href="#" class="btn" onclick="showLogs(); return false;">Logovi</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@ -70,6 +71,22 @@ document.getElementById('task-detail').addEventListener('click', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
function showLogs() {
|
||||
fetch('/api/logs/tail')
|
||||
.then(function(resp) { return resp.text(); })
|
||||
.then(function(text) {
|
||||
var el = document.getElementById('task-detail');
|
||||
el.innerHTML = '<div class="detail-overlay"><div class="detail-header"><h2>Poslednji logovi</h2><button class="detail-close" onclick="closeDetail()">×</button></div><div class="detail-body"><pre style="white-space:pre-wrap;font-size:13px;line-height:1.5">' + escapeHtml(text) + '</pre></div></div>';
|
||||
el.classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
var toast = document.getElementById('toast');
|
||||
toast.textContent = msg;
|
||||
@ -79,18 +96,39 @@ function showToast(msg, type) {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Handle "Proveri" button response
|
||||
document.body.addEventListener('htmx:afterRequest', function(e) {
|
||||
if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/review/)) {
|
||||
var xhr = e.detail.xhr;
|
||||
if (xhr.status === 200) {
|
||||
showToast('Pregled pokrenut', 'success');
|
||||
setTimeout(function() { window.location.href = '/console'; }, 800);
|
||||
} else {
|
||||
try {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
showToast(data.error || 'Greška', 'error');
|
||||
} catch(ex) {
|
||||
showToast('Greška', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle "Pusti" button response
|
||||
document.body.addEventListener('htmx:afterRequest', function(e) {
|
||||
if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/run/)) {
|
||||
var xhr = e.detail.xhr;
|
||||
if (xhr.status === 200) {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
showToast(data.exec_id ? 'Pokrenuto u sesiji ' + data.session : 'Pokrenuto', 'success');
|
||||
// Redirect to console after short delay so user can see output
|
||||
showToast('Pokrenuto: ' + (data.task || ''), 'success');
|
||||
setTimeout(function() { window.location.href = '/console'; }, 800);
|
||||
} else {
|
||||
try {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
showToast(data.error || 'Greška', 'error');
|
||||
} catch(ex) {
|
||||
showToast('Greška', 'error');
|
||||
}
|
||||
}
|
||||
htmx.ajax('GET', '/', {target: '#board', swap: 'outerHTML', select: '#board'});
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
</div>
|
||||
{{if .HasReport}}
|
||||
<div class="detail-actions">
|
||||
<a href="/report/{{.Task.ID}}" class="btn" target="_blank">Izvestaj</a>
|
||||
<button class="btn btn-report" hx-get="/report/{{.Task.ID}}" hx-target="#task-detail" hx-swap="innerHTML">Izvestaj</button>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="detail-actions">
|
||||
@ -21,6 +21,7 @@
|
||||
<button class="btn btn-run" hx-post="/task/{{.Task.ID}}/run" hx-swap="none" onclick="closeDetail()">Pusti</button>
|
||||
{{end}}
|
||||
{{if eq .Task.Status "review"}}
|
||||
<button class="btn btn-run" hx-post="/task/{{.Task.ID}}/review" hx-swap="none" onclick="closeDetail()">Proveri</button>
|
||||
<button class="btn btn-success" hx-post="/task/{{.Task.ID}}/move?to=done" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Odobri</button>
|
||||
<button class="btn btn-move" hx-post="/task/{{.Task.ID}}/move?to=ready" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Vrati</button>
|
||||
{{end}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user