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
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sessionState represents the state of a console session.
|
// taskSession represents a PTY session tied to a specific task.
|
||||||
type sessionState struct {
|
type taskSession struct {
|
||||||
mu sync.Mutex
|
TaskID string
|
||||||
status string // "idle" or "running"
|
Type string // "work" or "review"
|
||||||
cmd *exec.Cmd
|
PTY *consolePTYSession
|
||||||
ptySess *consolePTYSession
|
Started time.Time
|
||||||
execID string
|
|
||||||
taskID string // which task is being worked on (if any)
|
|
||||||
history []historyEntry
|
|
||||||
output []string
|
|
||||||
listeners map[chan string]bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// historyEntry represents a command in the session history.
|
// taskSessionResponse is the JSON representation of a task session.
|
||||||
type historyEntry struct {
|
type taskSessionResponse struct {
|
||||||
Command string `json:"command"`
|
TaskID string `json:"task_id"`
|
||||||
ExecID string `json:"exec_id"`
|
Type string `json:"type"`
|
||||||
Timestamp string `json:"timestamp"`
|
Status string `json:"status"` // "running" or "exited"
|
||||||
Status string `json:"status"` // "running", "done", "error", "killed"
|
Started string `json:"started"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// execRequest is the JSON body for starting a command.
|
// taskSessionManager manages dynamic PTY sessions per task.
|
||||||
type execRequest struct {
|
type taskSessionManager struct {
|
||||||
Cmd string `json:"cmd"`
|
mu sync.RWMutex
|
||||||
Session int `json:"session"`
|
sessions map[string]*taskSession
|
||||||
}
|
}
|
||||||
|
|
||||||
// execResponse is the JSON response after starting a command.
|
func newTaskSessionManager() *taskSessionManager {
|
||||||
type execResponse struct {
|
return &taskSessionManager{
|
||||||
ExecID string `json:"exec_id"`
|
sessions: make(map[string]*taskSession),
|
||||||
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)},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nextExecID generates a unique execution ID.
|
// sessionKey returns a unique key for a task session.
|
||||||
func (cm *consoleManager) nextExecID() string {
|
func sessionKey(taskID, sessionType string) string {
|
||||||
cm.mu.Lock()
|
if sessionType == "review" {
|
||||||
defer cm.mu.Unlock()
|
return taskID + "-review"
|
||||||
cm.counter++
|
}
|
||||||
return fmt.Sprintf("exec-%d-%d", time.Now().Unix(), cm.counter)
|
return taskID
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSession returns a session by index (0 or 1).
|
// startSession spawns an interactive claude PTY and sends the task prompt.
|
||||||
func (cm *consoleManager) getSession(idx int) *sessionState {
|
func (sm *taskSessionManager) startSession(taskID, sessionType, projectDir, prompt string) (*taskSession, error) {
|
||||||
if idx < 0 || idx > 1 {
|
key := sessionKey(taskID, sessionType)
|
||||||
return nil
|
|
||||||
|
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.
|
ptySess, err := spawnTaskPTY(projectDir)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("PTY spawn error for %s: %v", execID, err)
|
return nil, err
|
||||||
s.finishSession(session, execID, "error")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
log.Printf("PTY spawned for %s (PID %d)", execID, ptySess.Cmd.Process.Pid)
|
|
||||||
|
|
||||||
session.mu.Lock()
|
sess := &taskSession{
|
||||||
session.cmd = ptySess.Cmd
|
TaskID: taskID,
|
||||||
session.ptySess = ptySess
|
Type: sessionType,
|
||||||
session.mu.Unlock()
|
PTY: ptySess,
|
||||||
|
Started: time.Now(),
|
||||||
// Wait for process to exit
|
|
||||||
<-ptySess.Done()
|
|
||||||
|
|
||||||
status := "done"
|
|
||||||
if ptySess.Cmd.ProcessState != nil && !ptySess.Cmd.ProcessState.Success() {
|
|
||||||
status = "error"
|
|
||||||
}
|
}
|
||||||
log.Printf("PTY finished for %s (status: %s)", execID, status)
|
|
||||||
|
|
||||||
// Note: we do NOT clear session.ptySess here — the WS handler
|
sm.mu.Lock()
|
||||||
// needs it for replay buffer even after the process exits.
|
sm.sessions[key] = sess
|
||||||
// It gets replaced when a new command starts.
|
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.
|
// Send the task prompt after claude initializes
|
||||||
func (s *Server) sendToSession(session *sessionState, line string) {
|
go func() {
|
||||||
session.mu.Lock()
|
subID := fmt.Sprintf("init-%d", time.Now().UnixNano())
|
||||||
defer session.mu.Unlock()
|
ch := ptySess.Subscribe(subID)
|
||||||
|
|
||||||
session.output = append(session.output, line)
|
timer := time.NewTimer(30 * time.Second)
|
||||||
|
|
||||||
for ch := range session.listeners {
|
|
||||||
select {
|
select {
|
||||||
case ch <- line:
|
case <-ch:
|
||||||
default:
|
// 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)
|
||||||
|
|
||||||
// finishSession marks a session as idle and notifies listeners.
|
// Let claude fully render its welcome screen
|
||||||
func (s *Server) finishSession(session *sessionState, execID, status string) {
|
time.Sleep(2 * time.Second)
|
||||||
session.mu.Lock()
|
|
||||||
defer session.mu.Unlock()
|
|
||||||
|
|
||||||
session.status = "idle"
|
// Type the prompt
|
||||||
session.cmd = nil
|
log.Printf("Session[%s]: sending prompt (%d bytes)", key, len(prompt))
|
||||||
|
ptySess.WriteInput([]byte(prompt + "\n"))
|
||||||
// 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"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set SSE headers
|
|
||||||
c.Header("Content-Type", "text/event-stream")
|
|
||||||
c.Header("Cache-Control", "no-cache")
|
|
||||||
c.Header("Connection", "keep-alive")
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
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 {
|
select {
|
||||||
case <-notify:
|
case <-sess.PTY.Done():
|
||||||
return
|
status = "exited"
|
||||||
case line := <-ch:
|
default:
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// killSession terminates a session and removes it.
|
||||||
func (s *Server) handleConsoleKill(c *gin.Context) {
|
func (sm *taskSessionManager) killSession(taskID, sessionType string) bool {
|
||||||
sessionNum, err := strconv.Atoi(c.Param("session"))
|
key := sessionKey(taskID, sessionType)
|
||||||
if err != nil || sessionNum < 1 || sessionNum > 2 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidna sesija"})
|
sm.mu.Lock()
|
||||||
return
|
sess, exists := sm.sessions[key]
|
||||||
|
if exists {
|
||||||
|
delete(sm.sessions, key)
|
||||||
|
}
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
session := s.console.getSession(sessionNum - 1)
|
log.Printf("Session[%s]: killed", key)
|
||||||
|
sess.PTY.Close()
|
||||||
session.mu.Lock()
|
return true
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConsolePage serves the console HTML page.
|
// 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.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
c.String(http.StatusOK, renderConsolePage())
|
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
|
lastActive time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// spawnConsolePTY starts a new claude CLI in a PTY for the console.
|
// spawnTaskPTY starts an interactive claude CLI with auto-permissions in a PTY.
|
||||||
func spawnConsolePTY(projectDir, prompt string) (*consolePTYSession, error) {
|
// Used for task work and review sessions. The prompt is sent after startup.
|
||||||
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt)
|
func spawnTaskPTY(projectDir string) (*consolePTYSession, error) {
|
||||||
|
cmd := exec.Command("claude", "--permission-mode", "dontAsk")
|
||||||
cmd.Dir = projectDir
|
cmd.Dir = projectDir
|
||||||
cmd.Env = cleanEnvForPTY()
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("start pty: %w", err)
|
return nil, fmt.Errorf("start pty: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sess := &consolePTYSession{
|
sess := &consolePTYSession{
|
||||||
ID: fmt.Sprintf("pty-%d", time.Now().UnixNano()),
|
ID: fmt.Sprintf("task-%d", time.Now().UnixNano()),
|
||||||
Ptmx: ptmx,
|
Ptmx: ptmx,
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
buffer: NewRingBuffer(outputBufferSize),
|
buffer: NewRingBuffer(outputBufferSize),
|
||||||
|
|||||||
@ -4,6 +4,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -22,7 +23,7 @@ import (
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Router *gin.Engine
|
Router *gin.Engine
|
||||||
console *consoleManager
|
console *taskSessionManager
|
||||||
events *eventBroker
|
events *eventBroker
|
||||||
chatMu sync.RWMutex
|
chatMu sync.RWMutex
|
||||||
chats map[string]*chatState
|
chats map[string]*chatState
|
||||||
@ -73,7 +74,7 @@ func New(cfg *config.Config) *Server {
|
|||||||
s := &Server{
|
s := &Server{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Router: router,
|
Router: router,
|
||||||
console: newConsoleManager(),
|
console: newTaskSessionManager(),
|
||||||
events: newEventBroker(cfg.TasksDir),
|
events: newEventBroker(cfg.TasksDir),
|
||||||
chats: make(map[string]*chatState),
|
chats: make(map[string]*chatState),
|
||||||
}
|
}
|
||||||
@ -106,6 +107,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.Router.GET("/task/:id", s.handleTaskDetail)
|
s.Router.GET("/task/:id", s.handleTaskDetail)
|
||||||
s.Router.POST("/task/:id/move", s.handleMoveTask)
|
s.Router.POST("/task/:id/move", s.handleMoveTask)
|
||||||
s.Router.POST("/task/:id/run", s.handleRunTask)
|
s.Router.POST("/task/:id/run", s.handleRunTask)
|
||||||
|
s.Router.POST("/task/:id/review", s.handleReviewTask)
|
||||||
s.Router.GET("/report/:id", s.handleReport)
|
s.Router.GET("/report/:id", s.handleReport)
|
||||||
|
|
||||||
// SSE events
|
// SSE events
|
||||||
@ -116,12 +118,9 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
// Console routes
|
// Console routes
|
||||||
s.Router.GET("/console", s.handleConsolePage)
|
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/sessions", s.handleConsoleSessions)
|
||||||
s.Router.GET("/console/history/:session", s.handleConsoleHistory)
|
s.Router.POST("/console/kill/:taskID", s.handleConsoleKill)
|
||||||
s.Router.GET("/console/ws/:session", s.handleConsoleWS)
|
s.Router.GET("/console/ws/:key", s.handleConsoleWS)
|
||||||
|
|
||||||
// Logs route
|
// Logs route
|
||||||
s.Router.GET("/api/logs/tail", s.handleLogsTail)
|
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")
|
taskPath := filepath.Join(s.Config.TasksDir, "active", id+".md")
|
||||||
appendTimestamp(taskPath, "Pokrenut (→active)")
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"status": "started",
|
"status": "started",
|
||||||
"task": id,
|
"task": id,
|
||||||
|
"session": id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1098,15 +1098,15 @@ func TestConsolePage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
if !containsStr(body, "Sesija 1") {
|
if !containsStr(body, "KAOS") {
|
||||||
t.Error("expected 'Sesija 1' in console page")
|
t.Error("expected 'KAOS' in console page")
|
||||||
}
|
}
|
||||||
if !containsStr(body, "Sesija 2") {
|
if !containsStr(body, "Konzola") {
|
||||||
t.Error("expected 'Sesija 2' in console page")
|
t.Error("expected 'Konzola' in console page")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConsoleSessions(t *testing.T) {
|
func TestConsoleSessions_Empty(t *testing.T) {
|
||||||
srv := setupTestServer(t)
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/console/sessions", nil)
|
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)
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
var statuses []sessionStatus
|
var sessions []taskSessionResponse
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &statuses); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &sessions); err != nil {
|
||||||
t.Fatalf("invalid JSON: %v", err)
|
t.Fatalf("invalid JSON: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(statuses) != 2 {
|
if len(sessions) != 0 {
|
||||||
t.Fatalf("expected 2 sessions, got %d", len(statuses))
|
t.Fatalf("expected 0 sessions, got %d", len(sessions))
|
||||||
}
|
|
||||||
if statuses[0].Status != "idle" || statuses[1].Status != "idle" {
|
|
||||||
t.Error("expected both sessions idle")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConsoleExec_InvalidSession(t *testing.T) {
|
func TestConsoleKill_NotFound(t *testing.T) {
|
||||||
srv := setupTestServer(t)
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
body := `{"cmd":"status","session":3}`
|
req := httptest.NewRequest(http.MethodPost, "/console/kill/T99", nil)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
srv.Router.ServeHTTP(w, req)
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
if w.Code != http.StatusNotFound {
|
||||||
t.Fatalf("expected 400 for invalid session, got %d", w.Code)
|
t.Fatalf("expected 404, 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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)
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||||
@ -1643,11 +1553,11 @@ func TestConsolePage_HasSessionToggle(t *testing.T) {
|
|||||||
srv.Router.ServeHTTP(w, req)
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
if !containsStr(body, "togglePanel2") {
|
if !containsStr(body, "refreshSessions") {
|
||||||
t.Error("expected togglePanel2 button in console page")
|
t.Error("expected refreshSessions function in console page")
|
||||||
}
|
}
|
||||||
if !containsStr(body, `+ Sesija 2`) {
|
if !containsStr(body, "/console/sessions") {
|
||||||
t.Error("expected '+ Sesija 2' toggle button")
|
t.Error("expected /console/sessions API call")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2070,15 +1980,15 @@ func TestConsolePage_HasWebSocket(t *testing.T) {
|
|||||||
srv.Router.ServeHTTP(w, req)
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
if !containsStr(body, "/console/ws/") {
|
if !containsStr(body, "console/ws/") {
|
||||||
t.Error("expected WebSocket URL /console/ws/ in console page")
|
t.Error("expected WebSocket URL console/ws/ in console page")
|
||||||
}
|
}
|
||||||
if !containsStr(body, "new WebSocket") {
|
if !containsStr(body, "new WebSocket") {
|
||||||
t.Error("expected WebSocket constructor in console page")
|
t.Error("expected WebSocket constructor in console page")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConsolePage_HasTerminalContainers(t *testing.T) {
|
func TestConsolePage_HasEmptyState(t *testing.T) {
|
||||||
srv := setupTestServer(t)
|
srv := setupTestServer(t)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
req := httptest.NewRequest(http.MethodGet, "/console", nil)
|
||||||
@ -2086,14 +1996,14 @@ func TestConsolePage_HasTerminalContainers(t *testing.T) {
|
|||||||
srv.Router.ServeHTTP(w, req)
|
srv.Router.ServeHTTP(w, req)
|
||||||
|
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
if !containsStr(body, `id="terminal-1"`) {
|
if !containsStr(body, "empty-state") {
|
||||||
t.Error("expected terminal-1 container")
|
t.Error("expected empty-state element")
|
||||||
}
|
}
|
||||||
if !containsStr(body, `id="terminal-2"`) {
|
if !containsStr(body, "Pusti") {
|
||||||
t.Error("expected terminal-2 container")
|
t.Error("expected 'Pusti' instruction in empty state")
|
||||||
}
|
}
|
||||||
if !containsStr(body, "console-terminal") {
|
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")
|
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"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -19,6 +20,26 @@ import (
|
|||||||
"github.com/dal/kaos/internal/supervisor"
|
"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.
|
// chatState manages an operator chat session backed by a claude CLI process.
|
||||||
type chatState struct {
|
type chatState struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@ -136,7 +157,7 @@ func (s *Server) handleChatSubmit(c *gin.Context) {
|
|||||||
string(claudeMD), context, req.Message)
|
string(claudeMD), context, req.Message)
|
||||||
|
|
||||||
// Create chat session
|
// Create chat session
|
||||||
chatID := s.console.nextExecID()
|
chatID := nextChatID()
|
||||||
chat := &chatState{
|
chat := &chatState{
|
||||||
id: chatID,
|
id: chatID,
|
||||||
listeners: make(map[chan string]bool),
|
listeners: make(map[chan string]bool),
|
||||||
|
|||||||
@ -2,10 +2,8 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
@ -24,37 +22,40 @@ type wsResizeMsg struct {
|
|||||||
Rows uint16 `json:"rows"`
|
Rows uint16 `json:"rows"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConsoleWS handles WebSocket connections for console terminals.
|
// handleConsoleWS handles WebSocket connections for task console terminals.
|
||||||
// Each connection gets its own independent shell PTY session.
|
// Each connection attaches to an existing PTY session by task key.
|
||||||
func (s *Server) handleConsoleWS(c *gin.Context) {
|
func (s *Server) handleConsoleWS(c *gin.Context) {
|
||||||
sessionNum := c.Param("session")
|
key := c.Param("key") // e.g., "T08" or "T08-review"
|
||||||
if sessionNum != "1" && sessionNum != "2" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "sesija mora biti 1 ili 2"})
|
sess := s.console.getSessionByKey(key)
|
||||||
|
if sess == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("WebSocket upgrade error: %v", err)
|
log.Printf("WS[%s]: upgrade error: %v", key, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
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 := sess.PTY
|
||||||
ptySess, err := spawnShellPTY(s.projectRoot())
|
|
||||||
if err != nil {
|
// Send replay buffer so the user sees existing output
|
||||||
log.Printf("WS[%s]: shell spawn error: %v", sessionNum, err)
|
replay := ptySess.GetBuffer()
|
||||||
conn.WriteMessage(websocket.TextMessage,
|
if len(replay) > 0 {
|
||||||
[]byte(fmt.Sprintf("\r\n\033[31m[Greška: %v]\033[0m\r\n", err)))
|
if err := conn.WriteMessage(websocket.BinaryMessage, replay); err != nil {
|
||||||
return
|
log.Printf("WS[%s]: replay write error: %v", key, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("WS[%s]: replayed %d bytes", key, len(replay))
|
||||||
}
|
}
|
||||||
defer ptySess.Close()
|
|
||||||
|
|
||||||
log.Printf("WS[%s]: shell started (PID %d)", sessionNum, ptySess.Cmd.Process.Pid)
|
// Subscribe to new PTY output
|
||||||
|
subID := key + "-ws"
|
||||||
subID := fmt.Sprintf("ws-%d", time.Now().UnixNano())
|
|
||||||
outputCh := ptySess.Subscribe(subID)
|
outputCh := ptySess.Subscribe(subID)
|
||||||
defer ptySess.Unsubscribe(subID)
|
defer ptySess.Unsubscribe(subID)
|
||||||
|
|
||||||
@ -87,7 +88,7 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
|
|||||||
go func() {
|
go func() {
|
||||||
select {
|
select {
|
||||||
case <-ptySess.Done():
|
case <-ptySess.Done():
|
||||||
log.Printf("WS[%s]: shell exited", sessionNum)
|
log.Printf("WS[%s]: process exited", key)
|
||||||
conn.WriteMessage(websocket.CloseMessage,
|
conn.WriteMessage(websocket.CloseMessage,
|
||||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"))
|
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"))
|
||||||
case <-stopCh:
|
case <-stopCh:
|
||||||
@ -99,7 +100,7 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
|
|||||||
_, msg, err := conn.ReadMessage()
|
_, msg, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
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
|
break
|
||||||
}
|
}
|
||||||
@ -108,14 +109,14 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
|
|||||||
var resize wsResizeMsg
|
var resize wsResizeMsg
|
||||||
if json.Unmarshal(msg, &resize) == nil && resize.Type == "resize" && resize.Cols > 0 && resize.Rows > 0 {
|
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 {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular keyboard input → PTY
|
// Regular keyboard input → PTY
|
||||||
if _, err := ptySess.WriteInput(msg); err != nil {
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -123,5 +124,5 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
|
|||||||
close(stopCh)
|
close(stopCh)
|
||||||
close(writeCh)
|
close(writeCh)
|
||||||
<-writeDone
|
<-writeDone
|
||||||
log.Printf("WS[%s]: disconnected", sessionNum)
|
log.Printf("WS[%s]: disconnected", key)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>🔧 KAOS Dashboard</h1>
|
<h1>KAOS Dashboard</h1>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="theme-toggle">
|
<div class="theme-toggle">
|
||||||
<button class="theme-btn" data-theme-mode="light" onclick="setTheme('light')" title="Svetla tema">☀️</button>
|
<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-container">
|
||||||
<div class="console-toolbar">
|
<div class="console-toolbar">
|
||||||
<button class="btn" id="toggle-panel" onclick="togglePanel2()">+ Sesija 2</button>
|
<span id="session-info">Sesije: 0</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="console-panels" id="panels">
|
||||||
<div class="console-panels">
|
<div class="console-empty" id="empty-state">
|
||||||
<div class="console-panel" id="panel-1">
|
<p>Nema aktivnih sesija. Kliknite "Pusti" na tasku da pokrenete rad.</p>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -83,13 +70,38 @@ function getTermTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Session state ────────────────────────────────────
|
// ── 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 theme = getTermTheme();
|
||||||
|
|
||||||
var term = new Terminal({
|
var term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: 'block',
|
cursorStyle: 'block',
|
||||||
@ -106,41 +118,22 @@ function initTerminal(idx) {
|
|||||||
var fitAddon = new FitAddon.FitAddon();
|
var fitAddon = new FitAddon.FitAddon();
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
term.loadAddon(new WebLinksAddon.WebLinksAddon());
|
term.loadAddon(new WebLinksAddon.WebLinksAddon());
|
||||||
term.open(containerEl);
|
term.open(termDiv);
|
||||||
|
|
||||||
// Keyboard input → WebSocket
|
terminals[key] = { term: term, fitAddon: fitAddon, ws: null };
|
||||||
term.onData(function(data) {
|
|
||||||
var ws = sessions[idx].ws;
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resize → WebSocket
|
termDiv.addEventListener('click', function() { term.focus(); });
|
||||||
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;
|
|
||||||
|
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
// Auto-connect WebSocket immediately
|
connectWS(key, term);
|
||||||
connectWS(idx);
|
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebSocket connection ─────────────────────────────
|
// ── WebSocket connection ─────────────────────────────
|
||||||
function connectWS(idx) {
|
function connectWS(key, term) {
|
||||||
var num = idx + 1;
|
var sess = terminals[key];
|
||||||
var sess = sessions[idx];
|
if (!sess) return;
|
||||||
|
|
||||||
if (sess.ws) {
|
if (sess.ws) {
|
||||||
sess.ws.close();
|
sess.ws.close();
|
||||||
@ -148,62 +141,95 @@ function connectWS(idx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
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);
|
var ws = new WebSocket(url);
|
||||||
ws.binaryType = 'arraybuffer';
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
ws.onopen = function() {
|
ws.onopen = function() {
|
||||||
setSessionUI(num, 'connected');
|
var el = document.getElementById('status-' + key);
|
||||||
var term = sess.term;
|
if (el) { el.textContent = 'connected'; el.className = 'session-status session-running'; }
|
||||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||||
term.focus();
|
term.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = function(event) {
|
ws.onmessage = function(event) {
|
||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
sess.term.write(new Uint8Array(event.data));
|
term.write(new Uint8Array(event.data));
|
||||||
} else {
|
} else {
|
||||||
sess.term.write(event.data);
|
term.write(event.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = function() {
|
ws.onclose = function() {
|
||||||
sess.ws = null;
|
sess.ws = null;
|
||||||
setSessionUI(num, 'disconnected');
|
var el = document.getElementById('status-' + key);
|
||||||
|
if (el) { el.textContent = 'disconnected'; el.className = 'session-status'; }
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = function() {
|
ws.onerror = function() {
|
||||||
sess.ws = null;
|
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;
|
sess.ws = ws;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UI helpers ───────────────────────────────────────
|
// ── Session management ───────────────────────────────
|
||||||
function setSessionUI(session, status) {
|
function killSession(taskID, type) {
|
||||||
var el = document.getElementById('status-' + session);
|
fetch('/console/kill/' + taskID + '?type=' + type, { method: 'POST' })
|
||||||
el.textContent = status;
|
.then(function(resp) { return resp.json(); })
|
||||||
el.className = 'session-status';
|
.then(function() { refreshSessions(); });
|
||||||
if (status === 'connected') el.className += ' session-running';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePanel2() {
|
function refreshSessions() {
|
||||||
var panel = document.getElementById('panel-2');
|
fetch('/console/sessions')
|
||||||
var btn = document.getElementById('toggle-panel');
|
.then(function(resp) { return resp.json(); })
|
||||||
if (panel.style.display === 'none') {
|
.then(function(sessions) {
|
||||||
panel.style.display = 'flex';
|
var info = document.getElementById('session-info');
|
||||||
btn.textContent = '- Sesija 2';
|
info.textContent = 'Sesije: ' + sessions.length;
|
||||||
if (!sessions[1].term) {
|
|
||||||
initTerminal(1);
|
if (sessions.length === 0) {
|
||||||
} else {
|
document.getElementById('empty-state').style.display = 'block';
|
||||||
sessions[1].fitAddon.fit();
|
} else {
|
||||||
if (!sessions[1].ws) connectWS(1);
|
document.getElementById('empty-state').style.display = 'none';
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
panel.style.display = 'none';
|
var currentKeys = {};
|
||||||
btn.textContent = '+ Sesija 2';
|
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 ───────────────────────────────────────
|
// ── Theme sync ───────────────────────────────────────
|
||||||
@ -212,25 +238,26 @@ window.setTheme = function(mode) {
|
|||||||
if (origSetTheme) origSetTheme(mode);
|
if (origSetTheme) origSetTheme(mode);
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
var theme = getTermTheme();
|
var theme = getTermTheme();
|
||||||
for (var i = 0; i < 2; i++) {
|
Object.keys(terminals).forEach(function(key) {
|
||||||
if (sessions[i].term) {
|
if (terminals[key].term) {
|
||||||
sessions[i].term.options.theme = theme;
|
terminals[key].term.options.theme = theme;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Window resize ────────────────────────────────────
|
// ── Window resize ────────────────────────────────────
|
||||||
window.addEventListener('resize', function() {
|
window.addEventListener('resize', function() {
|
||||||
for (var i = 0; i < 2; i++) {
|
Object.keys(terminals).forEach(function(key) {
|
||||||
if (sessions[i].fitAddon) {
|
if (terminals[key].fitAddon) {
|
||||||
sessions[i].fitAddon.fit();
|
terminals[key].fitAddon.fit();
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Initialize ───────────────────────────────────────
|
// ── Initialize ───────────────────────────────────────
|
||||||
initTerminal(0);
|
refreshSessions();
|
||||||
|
setInterval(refreshSessions, 5000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -34,6 +34,7 @@
|
|||||||
<a href="/docs" class="btn">Dokumenti</a>
|
<a href="/docs" class="btn">Dokumenti</a>
|
||||||
<a href="/console" class="btn">Konzola</a>
|
<a href="/console" class="btn">Konzola</a>
|
||||||
<a href="/submit" class="btn">Prijava</a>
|
<a href="/submit" class="btn">Prijava</a>
|
||||||
|
<a href="#" class="btn" onclick="showLogs(); return false;">Logovi</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</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) {
|
function showToast(msg, type) {
|
||||||
var toast = document.getElementById('toast');
|
var toast = document.getElementById('toast');
|
||||||
toast.textContent = msg;
|
toast.textContent = msg;
|
||||||
@ -79,18 +96,39 @@ function showToast(msg, type) {
|
|||||||
}, 2000);
|
}, 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
|
// Handle "Pusti" button response
|
||||||
document.body.addEventListener('htmx:afterRequest', function(e) {
|
document.body.addEventListener('htmx:afterRequest', function(e) {
|
||||||
if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/run/)) {
|
if (e.detail.pathInfo && e.detail.pathInfo.requestPath && e.detail.pathInfo.requestPath.match(/\/task\/T\d+\/run/)) {
|
||||||
var xhr = e.detail.xhr;
|
var xhr = e.detail.xhr;
|
||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
var data = JSON.parse(xhr.responseText);
|
var data = JSON.parse(xhr.responseText);
|
||||||
showToast(data.exec_id ? 'Pokrenuto u sesiji ' + data.session : 'Pokrenuto', 'success');
|
showToast('Pokrenuto: ' + (data.task || ''), 'success');
|
||||||
// Redirect to console after short delay so user can see output
|
|
||||||
setTimeout(function() { window.location.href = '/console'; }, 800);
|
setTimeout(function() { window.location.href = '/console'; }, 800);
|
||||||
} else {
|
} else {
|
||||||
var data = JSON.parse(xhr.responseText);
|
try {
|
||||||
showToast(data.error || 'Greška', 'error');
|
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'});
|
htmx.ajax('GET', '/', {target: '#board', swap: 'outerHTML', select: '#board'});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{if .HasReport}}
|
{{if .HasReport}}
|
||||||
<div class="detail-actions">
|
<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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="detail-actions">
|
<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>
|
<button class="btn btn-run" hx-post="/task/{{.Task.ID}}/run" hx-swap="none" onclick="closeDetail()">Pusti</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if eq .Task.Status "review"}}
|
{{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-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>
|
<button class="btn btn-move" hx-post="/task/{{.Task.ID}}/move?to=ready" hx-target="#board" hx-swap="outerHTML" onclick="closeDetail()">Vrati</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user