Files
byre/internal/server/terminal.go
T

171 lines
4.1 KiB
Go
Raw Normal View History

package server
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/coder/websocket"
2026-10-18 15:32:07 -07:00
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/terminal"
)
const (
terminalReadLimit = 1 << 20
terminalExited = websocket.StatusCode(4000)
)
var newTerminalSize = api.TerminalSize{Cols: 80, Rows: 24}
func (s *Server) terminalsChanged(taskID string) {
s.store.Broadcast(api.Event{Kind: "terminals.changed", TaskId: &taskID, Actor: "byre", Payload: map[string]any{}})
}
func taskTerminal(term *terminal.Terminal) api.TaskTerminal {
return api.TaskTerminal{Id: term.ID, Name: term.Name, StartedAt: term.Started().UTC()}
}
func (s *Server) ListTaskTerminals(w http.ResponseWriter, r *http.Request, taskID string) {
_, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
out := []api.TaskTerminal{}
for _, term := range s.terminals.List(taskID) {
out = append(out, taskTerminal(term))
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) StartTaskTerminal(w http.ResponseWriter, r *http.Request, taskID string) {
t, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
req := api.TerminalStart{}
if !readJSON(w, r, &req) {
return
}
name, command := "Shell", ""
if req.CommandId != nil {
c, err := s.store.SavedCommand(r.Context(), actor(r), *req.CommandId)
if err != nil {
writeStoreError(w, err)
return
}
name, command = c.Name, c.Command
}
term, err := s.terminals.Start(t, name, command, newTerminalSize)
if err != nil {
slog.Error("start terminal", "task", taskID, "err", err)
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, taskTerminal(term))
}
func (s *Server) CloseTaskTerminal(w http.ResponseWriter, r *http.Request, taskID, terminalID string) {
if !s.terminals.Kill(taskID, terminalID) {
writeError(w, http.StatusNotFound, fmt.Errorf("no terminal %s in task %s", terminalID, taskID))
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) GetTaskTerminal(w http.ResponseWriter, r *http.Request, taskID, terminalID string, params api.GetTaskTerminalParams) {
_, ok := s.activeTask(w, r, taskID)
if !ok {
return
}
size := api.TerminalSize{Cols: params.Cols, Rows: params.Rows}
if !terminal.ValidSize(size) {
writeError(w, http.StatusBadRequest, fmt.Errorf("cols and rows must be between 1 and %d", terminal.MaxSize))
return
}
term := s.terminals.Get(taskID, terminalID)
if term == nil {
writeError(w, http.StatusNotFound, fmt.Errorf("no terminal %s in task %s", terminalID, taskID))
return
}
conn, err := websocket.Accept(w, r, nil)
if err != nil {
slog.Warn("accept terminal websocket", "task", taskID, "err", err)
return
}
defer conn.CloseNow()
conn.SetReadLimit(terminalReadLimit)
term.Resize(size)
backlog, out := term.Subscribe()
if out == nil {
exit, _ := term.ExitStatus()
conn.Close(terminalExited, exit)
return
}
defer term.Unsubscribe(out)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go func() {
defer cancel()
terminalInput(ctx, conn, term, func() { s.acted(actor(r), r) })
}()
if len(backlog) > 0 {
err = conn.Write(ctx, websocket.MessageBinary, backlog)
if err != nil {
return
}
}
for {
select {
case <-ctx.Done():
return
case b, open := <-out:
if open {
err = conn.Write(ctx, websocket.MessageBinary, b)
if err != nil {
return
}
continue
}
exit, exited := term.ExitStatus()
if exited {
conn.Close(terminalExited, exit)
return
}
conn.Close(websocket.StatusTryAgainLater, "fell behind the terminal's output")
return
}
}
}
func terminalInput(ctx context.Context, conn *websocket.Conn, term *terminal.Terminal, acted func()) {
for {
typ, b, err := conn.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageBinary {
acted()
err = term.Write(b)
if err != nil {
slog.Debug("terminal input", "err", err)
}
continue
}
size := api.TerminalSize{}
err = json.Unmarshal(b, &size)
if err != nil || !terminal.ValidSize(size) {
conn.Close(websocket.StatusUnsupportedData, "resize must be a TerminalSize")
return
}
term.Resize(size)
}
}