feat: add supervisor prototype with embedded frontend
This commit is contained in:
75
internal/app/app.go
Normal file
75
internal/app/app.go
Normal file
@ -0,0 +1,75 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"supervisor/internal/config"
|
||||
"supervisor/internal/httpserver"
|
||||
"supervisor/internal/session"
|
||||
"supervisor/internal/store/memory"
|
||||
"supervisor/internal/supervisor"
|
||||
"supervisor/internal/util"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg config.Config
|
||||
logger *log.Logger
|
||||
httpServer *http.Server
|
||||
SessionManager *session.Manager
|
||||
Supervisor *supervisor.Manager
|
||||
}
|
||||
|
||||
func New(cfg config.Config) (*App, error) {
|
||||
logger := util.NewLogger()
|
||||
memStore := memory.NewStore()
|
||||
sessionManager := session.NewManager(memStore, nil)
|
||||
supervisorManager := supervisor.NewManager()
|
||||
|
||||
router, err := httpserver.NewRouter(httpserver.Dependencies{
|
||||
Logger: logger,
|
||||
Manager: sessionManager,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build router: %w", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
httpServer: srv,
|
||||
SessionManager: sessionManager,
|
||||
Supervisor: supervisorManager,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Run(ctx context.Context) error {
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
a.logger.Printf("HTTP server listening on %s", a.cfg.Addr)
|
||||
errCh <- a.httpServer.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = a.httpServer.Shutdown(shutdownCtx)
|
||||
return nil
|
||||
case err := <-errCh:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
15
internal/config/config.go
Normal file
15
internal/config/config.go
Normal file
@ -0,0 +1,15 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
addr := os.Getenv("SUPERVISOR_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
return Config{Addr: addr}
|
||||
}
|
||||
10
internal/domain/agent.go
Normal file
10
internal/domain/agent.go
Normal file
@ -0,0 +1,10 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Agent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
32
internal/domain/event.go
Normal file
32
internal/domain/event.go
Normal file
@ -0,0 +1,32 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTerminalOutput EventType = "terminal.output"
|
||||
EventSessionStatus EventType = "session.status"
|
||||
EventError EventType = "error"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
SessionID string `json:"session"`
|
||||
Payload any `json:"payload"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// TerminalOutputEvent carries raw terminal bytes as UTF-8 text.
|
||||
type TerminalOutputEvent struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type SessionStatusEvent struct {
|
||||
Status SessionStatus `json:"status"`
|
||||
ExitCode *int `json:"exitCode,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorEvent struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
20
internal/domain/run.go
Normal file
20
internal/domain/run.go
Normal file
@ -0,0 +1,20 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type RunStatus string
|
||||
|
||||
const (
|
||||
RunStatusPlanned RunStatus = "planned"
|
||||
RunStatusRunning RunStatus = "running"
|
||||
RunStatusDone RunStatus = "done"
|
||||
RunStatusFailed RunStatus = "failed"
|
||||
)
|
||||
|
||||
type Run struct {
|
||||
ID string `json:"id"`
|
||||
WorkflowID string `json:"workflowId"`
|
||||
RequestedBy string `json:"requestedBy"`
|
||||
Status RunStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
25
internal/domain/session.go
Normal file
25
internal/domain/session.go
Normal file
@ -0,0 +1,25 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
SessionStatusCreated SessionStatus = "created"
|
||||
SessionStatusRunning SessionStatus = "running"
|
||||
SessionStatusStopped SessionStatus = "stopped"
|
||||
SessionStatusExited SessionStatus = "exited"
|
||||
SessionStatusError SessionStatus = "error"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AgentID string `json:"agentId"`
|
||||
Command string `json:"command"`
|
||||
Status SessionStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
ExitedAt *time.Time `json:"exitedAt,omitempty"`
|
||||
ExitCode *int `json:"exitCode,omitempty"`
|
||||
}
|
||||
13
internal/httpserver/handlers/health.go
Normal file
13
internal/httpserver/handlers/health.go
Normal file
@ -0,0 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type HealthHandler struct{}
|
||||
|
||||
func (h HealthHandler) Handle(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
131
internal/httpserver/handlers/sessions.go
Normal file
131
internal/httpserver/handlers/sessions.go
Normal file
@ -0,0 +1,131 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"supervisor/internal/domain"
|
||||
"supervisor/internal/session"
|
||||
)
|
||||
|
||||
type SessionService interface {
|
||||
CreateSession(ctx context.Context, params session.CreateSessionParams) (domain.Session, error)
|
||||
StartSession(ctx context.Context, id string) error
|
||||
StopSession(ctx context.Context, id string) error
|
||||
DeleteSession(ctx context.Context, id string) error
|
||||
ListSessions(ctx context.Context) ([]domain.Session, error)
|
||||
GetSession(ctx context.Context, id string) (domain.Session, error)
|
||||
WriteInput(ctx context.Context, id string, input string) error
|
||||
Resize(ctx context.Context, id string, cols, rows int) error
|
||||
Subscribe(id string) (<-chan domain.Event, func(), error)
|
||||
Scrollback(id string) ([]byte, error)
|
||||
}
|
||||
|
||||
type SessionsHandler struct {
|
||||
manager SessionService
|
||||
}
|
||||
|
||||
func NewSessionsHandler(manager SessionService) *SessionsHandler {
|
||||
return &SessionsHandler{manager: manager}
|
||||
}
|
||||
|
||||
func (h *SessionsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
sessions, err := h.manager.ListSessions(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sessions)
|
||||
}
|
||||
|
||||
func (h *SessionsHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req session.CreateSessionParams
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
created, err := h.manager.CreateSession(r.Context(), req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := h.manager.StartSession(r.Context(), created.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
current, err := h.manager.GetSession(r.Context(), created.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, current)
|
||||
}
|
||||
|
||||
func (h *SessionsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
item, err := h.manager.GetSession(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, session.ErrSessionNotFound) {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (h *SessionsHandler) Input(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req session.InputRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := h.manager.WriteInput(r.Context(), id, req.Input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (h *SessionsHandler) Stop(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := h.manager.StopSession(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
item, err := h.manager.GetSession(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (h *SessionsHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := h.manager.DeleteSession(r.Context(), id); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, session.ErrSessionNotFound):
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
case errors.Is(err, session.ErrSessionRunning):
|
||||
writeError(w, http.StatusConflict, err)
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, err error) {
|
||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||
}
|
||||
155
internal/httpserver/handlers/ws_terminal.go
Normal file
155
internal/httpserver/handlers/ws_terminal.go
Normal file
@ -0,0 +1,155 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"supervisor/internal/domain"
|
||||
"supervisor/internal/ws"
|
||||
)
|
||||
|
||||
type WSTerminalHandler struct {
|
||||
manager SessionService
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
func NewWSTerminalHandler(manager SessionService) *WSTerminalHandler {
|
||||
return &WSTerminalHandler{
|
||||
manager: manager,
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSTerminalHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.PathValue("id")
|
||||
current, err := h.manager.GetSession(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := h.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
events, cancel, err := h.manager.Subscribe(sessionID)
|
||||
if err != nil {
|
||||
_ = conn.WriteJSON(errorEnvelope(sessionID, err.Error()))
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if err := conn.WriteJSON(map[string]any{
|
||||
"type": string(domain.EventSessionStatus),
|
||||
"session": sessionID,
|
||||
"payload": ws.SessionStatusPayload{Status: string(current.Status), ExitCode: current.ExitCode},
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
scrollback, _ := h.manager.Scrollback(sessionID)
|
||||
if len(scrollback) > 0 {
|
||||
if err := conn.WriteJSON(map[string]any{
|
||||
"type": string(domain.EventTerminalOutput),
|
||||
"session": sessionID,
|
||||
"payload": ws.TerminalOutputPayload{Data: string(scrollback)},
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx, stop := context.WithCancel(r.Context())
|
||||
defer stop()
|
||||
|
||||
readErr := make(chan error, 1)
|
||||
go func() {
|
||||
readErr <- h.readLoop(ctx, conn, sessionID)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-readErr:
|
||||
if err == nil || websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
return
|
||||
}
|
||||
_ = conn.WriteJSON(errorEnvelope(sessionID, err.Error()))
|
||||
return
|
||||
case event, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := conn.WriteJSON(toEnvelope(event)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSTerminalHandler) readLoop(ctx context.Context, conn *websocket.Conn, sessionID string) error {
|
||||
conn.SetReadLimit(1 << 20)
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
}
|
||||
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var envelope ws.Envelope
|
||||
if err := json.Unmarshal(msg, &envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch envelope.Type {
|
||||
case "terminal.input":
|
||||
var payload ws.TerminalInputPayload
|
||||
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.manager.WriteInput(ctx, sessionID, payload.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
case "terminal.resize":
|
||||
var payload ws.TerminalResizePayload
|
||||
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.manager.Resize(ctx, sessionID, payload.Cols, payload.Rows); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported message type: %s", envelope.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toEnvelope(event domain.Event) map[string]any {
|
||||
return map[string]any{
|
||||
"type": string(event.Type),
|
||||
"session": event.SessionID,
|
||||
"payload": event.Payload,
|
||||
}
|
||||
}
|
||||
|
||||
func errorEnvelope(sessionID string, message string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "error",
|
||||
"session": sessionID,
|
||||
"payload": ws.ErrorPayload{Message: message},
|
||||
}
|
||||
}
|
||||
27
internal/httpserver/middleware.go
Normal file
27
internal/httpserver/middleware.go
Normal file
@ -0,0 +1,27 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func LoggingMiddleware(logger *log.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
started := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
logger.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(started))
|
||||
})
|
||||
}
|
||||
|
||||
func RecoverMiddleware(logger *log.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
logger.Printf("panic: %v", rec)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
42
internal/httpserver/router.go
Normal file
42
internal/httpserver/router.go
Normal file
@ -0,0 +1,42 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"supervisor/internal/httpserver/handlers"
|
||||
"supervisor/internal/static"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
Logger *log.Logger
|
||||
Manager handlers.SessionService
|
||||
}
|
||||
|
||||
func NewRouter(deps Dependencies) (http.Handler, error) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
healthHandler := handlers.HealthHandler{}
|
||||
sessions := handlers.NewSessionsHandler(deps.Manager)
|
||||
wsHandler := handlers.NewWSTerminalHandler(deps.Manager)
|
||||
|
||||
mux.HandleFunc("GET /healthz", healthHandler.Handle)
|
||||
mux.HandleFunc("GET /api/sessions", sessions.List)
|
||||
mux.HandleFunc("POST /api/sessions", sessions.Create)
|
||||
mux.HandleFunc("GET /api/sessions/{id}", sessions.Get)
|
||||
mux.HandleFunc("POST /api/sessions/{id}/input", sessions.Input)
|
||||
mux.HandleFunc("POST /api/sessions/{id}/stop", sessions.Stop)
|
||||
mux.HandleFunc("DELETE /api/sessions/{id}", sessions.Delete)
|
||||
mux.HandleFunc("GET /ws/sessions/{id}", wsHandler.Handle)
|
||||
|
||||
staticHandler, err := static.NewSPAHandler()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mux.Handle("/", staticHandler)
|
||||
|
||||
var root http.Handler = mux
|
||||
root = RecoverMiddleware(deps.Logger, root)
|
||||
root = LoggingMiddleware(deps.Logger, root)
|
||||
return root, nil
|
||||
}
|
||||
158
internal/session/manager.go
Normal file
158
internal/session/manager.go
Normal file
@ -0,0 +1,158 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"supervisor/internal/domain"
|
||||
"supervisor/internal/store"
|
||||
"supervisor/internal/util"
|
||||
)
|
||||
|
||||
var ErrSessionNotFound = errors.New("session not found")
|
||||
var ErrSessionRunning = errors.New("session is running")
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
store store.SessionStore
|
||||
factory PTYFactory
|
||||
runtimes map[string]*Session
|
||||
scrollbackLimit int
|
||||
}
|
||||
|
||||
func NewManager(store store.SessionStore, factory PTYFactory) *Manager {
|
||||
if factory == nil {
|
||||
factory = DefaultPTYFactory{}
|
||||
}
|
||||
return &Manager{
|
||||
store: store,
|
||||
factory: factory,
|
||||
runtimes: make(map[string]*Session),
|
||||
scrollbackLimit: 512 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) CreateSession(ctx context.Context, params CreateSessionParams) (domain.Session, error) {
|
||||
command := params.Command
|
||||
if command == "" {
|
||||
command = "bash"
|
||||
}
|
||||
s := domain.Session{
|
||||
ID: util.NewID("sess"),
|
||||
Name: params.Name,
|
||||
AgentID: params.AgentID,
|
||||
Command: command,
|
||||
Status: domain.SessionStatusCreated,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if s.Name == "" {
|
||||
s.Name = s.ID
|
||||
}
|
||||
|
||||
runtime := NewSession(s, m.scrollbackLimit, func(session domain.Session) {
|
||||
_ = m.store.Upsert(context.Background(), session)
|
||||
})
|
||||
|
||||
m.mu.Lock()
|
||||
m.runtimes[s.ID] = runtime
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := m.store.Upsert(ctx, s); err != nil {
|
||||
return domain.Session{}, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *Manager) StartSession(_ context.Context, id string) error {
|
||||
runtime, err := m.runtimeByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runtime.Start(m.factory)
|
||||
}
|
||||
|
||||
func (m *Manager) StopSession(_ context.Context, id string) error {
|
||||
runtime, err := m.runtimeByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runtime.Stop()
|
||||
}
|
||||
|
||||
func (m *Manager) ListSessions(ctx context.Context) ([]domain.Session, error) {
|
||||
return m.store.List(ctx)
|
||||
}
|
||||
|
||||
func (m *Manager) GetSession(ctx context.Context, id string) (domain.Session, error) {
|
||||
s, ok, err := m.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Session{}, err
|
||||
}
|
||||
if !ok {
|
||||
return domain.Session{}, ErrSessionNotFound
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *Manager) WriteInput(_ context.Context, id string, input string) error {
|
||||
runtime, err := m.runtimeByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runtime.WriteInput(input)
|
||||
}
|
||||
|
||||
func (m *Manager) Subscribe(id string) (<-chan domain.Event, func(), error) {
|
||||
runtime, err := m.runtimeByID(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_, ch, cancel := runtime.Subscribe()
|
||||
return ch, cancel, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Resize(_ context.Context, id string, cols, rows int) error {
|
||||
runtime, err := m.runtimeByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runtime.Resize(cols, rows)
|
||||
}
|
||||
|
||||
func (m *Manager) Scrollback(id string) ([]byte, error) {
|
||||
runtime, err := m.runtimeByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runtime.Scrollback(), nil
|
||||
}
|
||||
|
||||
func (m *Manager) DeleteSession(ctx context.Context, id string) error {
|
||||
runtime, err := m.runtimeByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
snapshot := runtime.Snapshot()
|
||||
if snapshot.Status == domain.SessionStatusRunning {
|
||||
return ErrSessionRunning
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
delete(m.runtimes, id)
|
||||
m.mu.Unlock()
|
||||
|
||||
return m.store.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (m *Manager) runtimeByID(id string) (*Session, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
runtime, ok := m.runtimes[id]
|
||||
if !ok {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
return runtime, nil
|
||||
}
|
||||
20
internal/session/models.go
Normal file
20
internal/session/models.go
Normal file
@ -0,0 +1,20 @@
|
||||
package session
|
||||
|
||||
import "supervisor/internal/domain"
|
||||
|
||||
type CreateSessionParams struct {
|
||||
Name string `json:"name"`
|
||||
AgentID string `json:"agentId"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
type InputRequest struct {
|
||||
Input string `json:"input"`
|
||||
}
|
||||
|
||||
type ResizeRequest struct {
|
||||
Cols int `json:"cols"`
|
||||
Rows int `json:"rows"`
|
||||
}
|
||||
|
||||
type SessionSummary = domain.Session
|
||||
53
internal/session/process.go
Normal file
53
internal/session/process.go
Normal file
@ -0,0 +1,53 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
type DefaultPTYFactory struct{}
|
||||
|
||||
type shellProcess struct {
|
||||
cmd *exec.Cmd
|
||||
pty *os.File
|
||||
}
|
||||
|
||||
func (f DefaultPTYFactory) Start(command string) (PTYProcess, error) {
|
||||
cmd := exec.Command("bash", "-lc", command)
|
||||
cmd.Env = os.Environ()
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &shellProcess{cmd: cmd, pty: ptmx}, nil
|
||||
}
|
||||
|
||||
func (p *shellProcess) Read(b []byte) (int, error) {
|
||||
return p.pty.Read(b)
|
||||
}
|
||||
|
||||
func (p *shellProcess) Write(b []byte) (int, error) {
|
||||
return p.pty.Write(b)
|
||||
}
|
||||
|
||||
func (p *shellProcess) Close() error {
|
||||
return p.pty.Close()
|
||||
}
|
||||
|
||||
func (p *shellProcess) Wait() error {
|
||||
return p.cmd.Wait()
|
||||
}
|
||||
|
||||
func (p *shellProcess) Resize(cols, rows uint16) error {
|
||||
return pty.Setsize(p.pty, &pty.Winsize{Cols: cols, Rows: rows})
|
||||
}
|
||||
|
||||
func (p *shellProcess) SignalStop() error {
|
||||
if p.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return p.cmd.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
14
internal/session/pty.go
Normal file
14
internal/session/pty.go
Normal file
@ -0,0 +1,14 @@
|
||||
package session
|
||||
|
||||
import "io"
|
||||
|
||||
type PTYProcess interface {
|
||||
io.ReadWriteCloser
|
||||
Wait() error
|
||||
Resize(cols, rows uint16) error
|
||||
SignalStop() error
|
||||
}
|
||||
|
||||
type PTYFactory interface {
|
||||
Start(command string) (PTYProcess, error)
|
||||
}
|
||||
273
internal/session/session.go
Normal file
273
internal/session/session.go
Normal file
@ -0,0 +1,273 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"supervisor/internal/domain"
|
||||
"supervisor/internal/util"
|
||||
)
|
||||
|
||||
type StateChangeFn func(domain.Session)
|
||||
|
||||
type Session struct {
|
||||
mu sync.RWMutex
|
||||
meta domain.Session
|
||||
process PTYProcess
|
||||
subscribers map[string]chan domain.Event
|
||||
scrollback []byte
|
||||
scrollbackSize int
|
||||
onStateChange StateChangeFn
|
||||
}
|
||||
|
||||
func NewSession(meta domain.Session, scrollbackSize int, onStateChange StateChangeFn) *Session {
|
||||
if scrollbackSize <= 0 {
|
||||
scrollbackSize = 256 * 1024
|
||||
}
|
||||
return &Session{
|
||||
meta: meta,
|
||||
subscribers: make(map[string]chan domain.Event),
|
||||
scrollbackSize: scrollbackSize,
|
||||
onStateChange: onStateChange,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) Snapshot() domain.Session {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.meta
|
||||
}
|
||||
|
||||
func (s *Session) Scrollback() []byte {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cpy := make([]byte, len(s.scrollback))
|
||||
copy(cpy, s.scrollback)
|
||||
return cpy
|
||||
}
|
||||
|
||||
func (s *Session) Start(factory PTYFactory) error {
|
||||
s.mu.Lock()
|
||||
if s.meta.Status == domain.SessionStatusRunning {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if s.meta.Command == "" {
|
||||
s.mu.Unlock()
|
||||
return errors.New("empty command")
|
||||
}
|
||||
proc, err := factory.Start(s.meta.Command)
|
||||
if err != nil {
|
||||
now := time.Now().UTC()
|
||||
s.meta.Status = domain.SessionStatusError
|
||||
s.meta.ExitedAt = &now
|
||||
s.mu.Unlock()
|
||||
s.emitStateChange()
|
||||
s.publish(domain.Event{
|
||||
Type: domain.EventError,
|
||||
SessionID: s.meta.ID,
|
||||
Payload: domain.ErrorEvent{
|
||||
Message: err.Error(),
|
||||
},
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
s.process = proc
|
||||
s.meta.Status = domain.SessionStatusRunning
|
||||
s.meta.StartedAt = &now
|
||||
s.meta.ExitedAt = nil
|
||||
s.meta.ExitCode = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emitStateChange()
|
||||
s.publishStatus()
|
||||
|
||||
go s.readLoop(proc)
|
||||
go s.waitLoop(proc)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) Stop() error {
|
||||
s.mu.Lock()
|
||||
if s.process == nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
proc := s.process
|
||||
if s.meta.Status == domain.SessionStatusRunning {
|
||||
s.meta.Status = domain.SessionStatusStopped
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emitStateChange()
|
||||
s.publishStatus()
|
||||
return proc.SignalStop()
|
||||
}
|
||||
|
||||
func (s *Session) WriteInput(input string) error {
|
||||
s.mu.RLock()
|
||||
proc := s.process
|
||||
status := s.meta.Status
|
||||
s.mu.RUnlock()
|
||||
|
||||
if proc == nil || status != domain.SessionStatusRunning {
|
||||
return errors.New("session is not running")
|
||||
}
|
||||
_, err := proc.Write([]byte(input))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Session) Resize(cols, rows int) error {
|
||||
if cols <= 0 || rows <= 0 {
|
||||
return errors.New("invalid terminal size")
|
||||
}
|
||||
s.mu.RLock()
|
||||
proc := s.process
|
||||
s.mu.RUnlock()
|
||||
if proc == nil {
|
||||
return errors.New("session has no process")
|
||||
}
|
||||
return proc.Resize(uint16(cols), uint16(rows))
|
||||
}
|
||||
|
||||
func (s *Session) Subscribe() (string, <-chan domain.Event, func()) {
|
||||
id := util.NewID("sub")
|
||||
ch := make(chan domain.Event, 128)
|
||||
s.mu.Lock()
|
||||
s.subscribers[id] = ch
|
||||
s.mu.Unlock()
|
||||
cancel := func() {
|
||||
s.mu.Lock()
|
||||
sub, ok := s.subscribers[id]
|
||||
if ok {
|
||||
delete(s.subscribers, id)
|
||||
close(sub)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
return id, ch, cancel
|
||||
}
|
||||
|
||||
func (s *Session) readLoop(proc PTYProcess) {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := proc.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := append([]byte(nil), buf[:n]...)
|
||||
s.appendScrollback(chunk)
|
||||
s.publish(domain.Event{
|
||||
Type: domain.EventTerminalOutput,
|
||||
SessionID: s.Snapshot().ID,
|
||||
Payload: domain.TerminalOutputEvent{
|
||||
Data: string(chunk),
|
||||
},
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, io.EOF) {
|
||||
s.publish(domain.Event{
|
||||
Type: domain.EventError,
|
||||
SessionID: s.Snapshot().ID,
|
||||
Payload: domain.ErrorEvent{Message: err.Error()},
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) waitLoop(proc PTYProcess) {
|
||||
err := proc.Wait()
|
||||
_ = proc.Close()
|
||||
|
||||
var exitCode *int
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
code := exitErr.ExitCode()
|
||||
exitCode = &code
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
code := 0
|
||||
exitCode = &code
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
s.mu.Lock()
|
||||
if s.meta.Status != domain.SessionStatusError {
|
||||
s.meta.Status = domain.SessionStatusExited
|
||||
}
|
||||
s.meta.ExitedAt = &now
|
||||
s.meta.ExitCode = exitCode
|
||||
s.process = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
s.emitStateChange()
|
||||
s.publishStatus()
|
||||
|
||||
if err != nil {
|
||||
s.publish(domain.Event{
|
||||
Type: domain.EventError,
|
||||
SessionID: s.Snapshot().ID,
|
||||
Payload: domain.ErrorEvent{Message: err.Error()},
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) appendScrollback(data []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(data) >= s.scrollbackSize {
|
||||
s.scrollback = append([]byte(nil), data[len(data)-s.scrollbackSize:]...)
|
||||
return
|
||||
}
|
||||
s.scrollback = append(s.scrollback, data...)
|
||||
if len(s.scrollback) > s.scrollbackSize {
|
||||
extra := len(s.scrollback) - s.scrollbackSize
|
||||
s.scrollback = append([]byte(nil), s.scrollback[extra:]...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) publishStatus() {
|
||||
snap := s.Snapshot()
|
||||
s.publish(domain.Event{
|
||||
Type: domain.EventSessionStatus,
|
||||
SessionID: snap.ID,
|
||||
Payload: domain.SessionStatusEvent{
|
||||
Status: snap.Status,
|
||||
ExitCode: snap.ExitCode,
|
||||
},
|
||||
At: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) publish(event domain.Event) {
|
||||
s.mu.RLock()
|
||||
subs := make([]chan domain.Event, 0, len(s.subscribers))
|
||||
for _, ch := range s.subscribers {
|
||||
subs = append(subs, ch)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- event:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) emitStateChange() {
|
||||
if s.onStateChange == nil {
|
||||
return
|
||||
}
|
||||
s.onStateChange(s.Snapshot())
|
||||
}
|
||||
10
internal/static/dist/index.html
vendored
Normal file
10
internal/static/dist/index.html
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Supervisor</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Frontend not built yet. Run make frontend-build.</p>
|
||||
</body>
|
||||
</html>
|
||||
7
internal/static/embed.go
Normal file
7
internal/static/embed.go
Normal file
@ -0,0 +1,7 @@
|
||||
package static
|
||||
|
||||
import "embed"
|
||||
|
||||
// DistFS contains built frontend assets copied into internal/static/dist.
|
||||
//go:embed dist dist/*
|
||||
var DistFS embed.FS
|
||||
52
internal/static/serve.go
Normal file
52
internal/static/serve.go
Normal file
@ -0,0 +1,52 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
)
|
||||
|
||||
type SPAHandler struct {
|
||||
assets fs.FS
|
||||
files http.Handler
|
||||
}
|
||||
|
||||
func NewSPAHandler() (http.Handler, error) {
|
||||
sub, err := fs.Sub(DistFS, "dist")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SPAHandler{
|
||||
assets: sub,
|
||||
files: http.FileServer(http.FS(sub)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *SPAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
requested := path.Clean(r.URL.Path)
|
||||
if requested == "." || requested == "/" {
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
candidate := requested[1:]
|
||||
if candidate == "" {
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
if file, err := h.assets.Open(candidate); err == nil {
|
||||
_ = file.Close()
|
||||
h.files.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
h.serveIndex(w, r)
|
||||
}
|
||||
|
||||
func (h *SPAHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
index, err := fs.ReadFile(h.assets, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "index.html not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(index)
|
||||
}
|
||||
51
internal/store/memory/store.go
Normal file
51
internal/store/memory/store.go
Normal file
@ -0,0 +1,51 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"supervisor/internal/domain"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]domain.Session
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{sessions: make(map[string]domain.Session)}
|
||||
}
|
||||
|
||||
func (s *Store) Upsert(_ context.Context, session domain.Session) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[session.ID] = session
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(_ context.Context, id string) (domain.Session, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
session, ok := s.sessions[id]
|
||||
return session, ok, nil
|
||||
}
|
||||
|
||||
func (s *Store) List(_ context.Context) ([]domain.Session, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
items := make([]domain.Session, 0, len(s.sessions))
|
||||
for _, session := range s.sessions {
|
||||
items = append(items, session)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].CreatedAt.After(items[j].CreatedAt)
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(_ context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, id)
|
||||
return nil
|
||||
}
|
||||
13
internal/store/types.go
Normal file
13
internal/store/types.go
Normal file
@ -0,0 +1,13 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"supervisor/internal/domain"
|
||||
)
|
||||
|
||||
type SessionStore interface {
|
||||
Upsert(ctx context.Context, session domain.Session) error
|
||||
Get(ctx context.Context, id string) (domain.Session, bool, error)
|
||||
List(ctx context.Context) ([]domain.Session, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
13
internal/supervisor/manager.go
Normal file
13
internal/supervisor/manager.go
Normal file
@ -0,0 +1,13 @@
|
||||
package supervisor
|
||||
|
||||
import "sync"
|
||||
|
||||
// Manager is a future extension point for high-level orchestration.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
assignments map[string]AgentAssignment
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{assignments: make(map[string]AgentAssignment)}
|
||||
}
|
||||
9
internal/supervisor/models.go
Normal file
9
internal/supervisor/models.go
Normal file
@ -0,0 +1,9 @@
|
||||
package supervisor
|
||||
|
||||
import "time"
|
||||
|
||||
type AgentAssignment struct {
|
||||
AgentID string `json:"agentId"`
|
||||
SessionID string `json:"sessionId"`
|
||||
AssignedAt time.Time `json:"assignedAt"`
|
||||
}
|
||||
12
internal/util/ids.go
Normal file
12
internal/util/ids.go
Normal file
@ -0,0 +1,12 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func NewID(prefix string) string {
|
||||
buf := make([]byte, 8)
|
||||
_, _ = rand.Read(buf)
|
||||
return prefix + "_" + hex.EncodeToString(buf)
|
||||
}
|
||||
10
internal/util/logger.go
Normal file
10
internal/util/logger.go
Normal file
@ -0,0 +1,10 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func NewLogger() *log.Logger {
|
||||
return log.New(os.Stdout, "[supervisor] ", log.LstdFlags|log.Lmicroseconds)
|
||||
}
|
||||
31
internal/ws/messages.go
Normal file
31
internal/ws/messages.go
Normal file
@ -0,0 +1,31 @@
|
||||
package ws
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type Envelope struct {
|
||||
Type string `json:"type"`
|
||||
Session string `json:"session"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type TerminalInputPayload struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type TerminalOutputPayload struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type TerminalResizePayload struct {
|
||||
Cols int `json:"cols"`
|
||||
Rows int `json:"rows"`
|
||||
}
|
||||
|
||||
type SessionStatusPayload struct {
|
||||
Status string `json:"status"`
|
||||
ExitCode *int `json:"exitCode,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorPayload struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
Reference in New Issue
Block a user