Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
415 lines
15 KiB
Go
415 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/coder/websocket"
|
|
tuttigenerated "github.com/tutti-os/tutti/services/tuttid/api/generated"
|
|
"github.com/tutti-os/tutti/services/tuttid/apierrors"
|
|
workspaceservice "github.com/tutti-os/tutti/services/tuttid/service/workspace"
|
|
tuttitypes "github.com/tutti-os/tutti/services/tuttid/types"
|
|
)
|
|
|
|
func terminalServiceUnavailableError() tuttigenerated.ServiceUnavailableErrorJSONResponse {
|
|
return serviceUnavailableError(
|
|
apierrors.WorkspaceTerminalServiceUnavailable(
|
|
apierrors.WithDeveloperMessage("workspace terminal service is unavailable"),
|
|
),
|
|
)
|
|
}
|
|
|
|
func (api DaemonAPI) ListWorkspaceTerminals(ctx context.Context, request tuttigenerated.ListWorkspaceTerminalsRequestObject) (tuttigenerated.ListWorkspaceTerminalsResponseObject, error) {
|
|
if api.TerminalService == nil {
|
|
return tuttigenerated.ListWorkspaceTerminals503JSONResponse{
|
|
ServiceUnavailableErrorJSONResponse: terminalServiceUnavailableError(),
|
|
}, nil
|
|
}
|
|
sessions, err := api.TerminalService.List(ctx, string(request.WorkspaceID))
|
|
if err != nil {
|
|
return writeListWorkspaceTerminalsError(err), nil
|
|
}
|
|
return tuttigenerated.ListWorkspaceTerminals200JSONResponse{
|
|
Terminals: generatedTerminalSessions(sessions),
|
|
WorkspaceId: string(request.WorkspaceID),
|
|
}, nil
|
|
}
|
|
|
|
func (api DaemonAPI) CreateWorkspaceTerminal(ctx context.Context, request tuttigenerated.CreateWorkspaceTerminalRequestObject) (tuttigenerated.CreateWorkspaceTerminalResponseObject, error) {
|
|
if api.TerminalService == nil {
|
|
return tuttigenerated.CreateWorkspaceTerminal503JSONResponse{
|
|
ServiceUnavailableErrorJSONResponse: terminalServiceUnavailableError(),
|
|
}, nil
|
|
}
|
|
if request.Body == nil {
|
|
return tuttigenerated.CreateWorkspaceTerminal400JSONResponse{
|
|
InvalidRequestErrorJSONResponse: invalidRequestError(apierrors.EmptyBody(apierrors.WithDeveloperMessage("empty body"))),
|
|
}, nil
|
|
}
|
|
session, err := api.TerminalService.Create(ctx, string(request.WorkspaceID), workspaceservice.CreateTerminalInput{
|
|
Cols: request.Body.Cols,
|
|
Cwd: request.Body.Cwd,
|
|
InitialInput: request.Body.InitialInput,
|
|
ProfileID: request.Body.ProfileId,
|
|
Rows: request.Body.Rows,
|
|
})
|
|
if err != nil {
|
|
return writeCreateWorkspaceTerminalError(err), nil
|
|
}
|
|
return tuttigenerated.CreateWorkspaceTerminal201JSONResponse{
|
|
Terminal: generatedTerminalSession(session),
|
|
}, nil
|
|
}
|
|
|
|
func (api DaemonAPI) GetWorkspaceTerminal(ctx context.Context, request tuttigenerated.GetWorkspaceTerminalRequestObject) (tuttigenerated.GetWorkspaceTerminalResponseObject, error) {
|
|
if api.TerminalService == nil {
|
|
return tuttigenerated.GetWorkspaceTerminal503JSONResponse{
|
|
ServiceUnavailableErrorJSONResponse: terminalServiceUnavailableError(),
|
|
}, nil
|
|
}
|
|
session, err := api.TerminalService.Get(ctx, string(request.WorkspaceID), string(request.TerminalID))
|
|
if err != nil {
|
|
return writeGetWorkspaceTerminalError(err), nil
|
|
}
|
|
return tuttigenerated.GetWorkspaceTerminal200JSONResponse{
|
|
Terminal: generatedTerminalSession(session),
|
|
}, nil
|
|
}
|
|
|
|
func (api DaemonAPI) TerminateWorkspaceTerminal(ctx context.Context, request tuttigenerated.TerminateWorkspaceTerminalRequestObject) (tuttigenerated.TerminateWorkspaceTerminalResponseObject, error) {
|
|
if api.TerminalService == nil {
|
|
return tuttigenerated.TerminateWorkspaceTerminal503JSONResponse{
|
|
ServiceUnavailableErrorJSONResponse: terminalServiceUnavailableError(),
|
|
}, nil
|
|
}
|
|
session, err := api.TerminalService.Terminate(ctx, string(request.WorkspaceID), string(request.TerminalID))
|
|
if err != nil {
|
|
return writeTerminateWorkspaceTerminalError(err), nil
|
|
}
|
|
return tuttigenerated.TerminateWorkspaceTerminal200JSONResponse{
|
|
Terminal: generatedTerminalSession(session),
|
|
}, nil
|
|
}
|
|
|
|
func (api DaemonAPI) CheckWorkspaceTerminalCloseGuard(ctx context.Context, request tuttigenerated.CheckWorkspaceTerminalCloseGuardRequestObject) (tuttigenerated.CheckWorkspaceTerminalCloseGuardResponseObject, error) {
|
|
if api.TerminalService == nil {
|
|
return tuttigenerated.CheckWorkspaceTerminalCloseGuard503JSONResponse{
|
|
ServiceUnavailableErrorJSONResponse: terminalServiceUnavailableError(),
|
|
}, nil
|
|
}
|
|
guard, err := api.TerminalService.CloseGuard(ctx, string(request.WorkspaceID), string(request.TerminalID))
|
|
if err != nil {
|
|
return writeCheckWorkspaceTerminalCloseGuardError(err), nil
|
|
}
|
|
return tuttigenerated.CheckWorkspaceTerminalCloseGuard200JSONResponse{
|
|
Guard: generatedTerminalCloseGuard(guard),
|
|
}, nil
|
|
}
|
|
|
|
func (api DaemonAPI) ResizeWorkspaceTerminal(ctx context.Context, request tuttigenerated.ResizeWorkspaceTerminalRequestObject) (tuttigenerated.ResizeWorkspaceTerminalResponseObject, error) {
|
|
if api.TerminalService == nil {
|
|
return tuttigenerated.ResizeWorkspaceTerminal503JSONResponse{
|
|
ServiceUnavailableErrorJSONResponse: terminalServiceUnavailableError(),
|
|
}, nil
|
|
}
|
|
if request.Body == nil {
|
|
return tuttigenerated.ResizeWorkspaceTerminal400JSONResponse{
|
|
InvalidRequestErrorJSONResponse: invalidRequestError(apierrors.EmptyBody(apierrors.WithDeveloperMessage("empty body"))),
|
|
}, nil
|
|
}
|
|
session, err := api.TerminalService.Resize(ctx, string(request.WorkspaceID), string(request.TerminalID), workspaceservice.ResizeTerminalInput{
|
|
Cols: request.Body.Cols,
|
|
Rows: request.Body.Rows,
|
|
})
|
|
if err != nil {
|
|
return writeResizeWorkspaceTerminalError(err), nil
|
|
}
|
|
return tuttigenerated.ResizeWorkspaceTerminal200JSONResponse{
|
|
Terminal: generatedTerminalSession(session),
|
|
}, nil
|
|
}
|
|
|
|
func (api DaemonAPI) GetWorkspaceTerminalSnapshot(ctx context.Context, request tuttigenerated.GetWorkspaceTerminalSnapshotRequestObject) (tuttigenerated.GetWorkspaceTerminalSnapshotResponseObject, error) {
|
|
if api.TerminalService == nil {
|
|
return tuttigenerated.GetWorkspaceTerminalSnapshot503JSONResponse{
|
|
ServiceUnavailableErrorJSONResponse: terminalServiceUnavailableError(),
|
|
}, nil
|
|
}
|
|
snapshot, err := api.TerminalService.Snapshot(ctx, string(request.WorkspaceID), string(request.TerminalID))
|
|
if err != nil {
|
|
return writeGetWorkspaceTerminalSnapshotError(err), nil
|
|
}
|
|
return tuttigenerated.GetWorkspaceTerminalSnapshot200JSONResponse{
|
|
Snapshot: generatedTerminalSnapshot(snapshot),
|
|
}, nil
|
|
}
|
|
|
|
func (DaemonAPI) AttachWorkspaceTerminal(context.Context, tuttigenerated.AttachWorkspaceTerminalRequestObject) (tuttigenerated.AttachWorkspaceTerminalResponseObject, error) {
|
|
return tuttigenerated.AttachWorkspaceTerminal503JSONResponse{
|
|
ServiceUnavailableErrorJSONResponse: terminalServiceUnavailableError(),
|
|
}, nil
|
|
}
|
|
|
|
type terminalWebSocketClientFrame struct {
|
|
Cols *int `json:"cols,omitempty"`
|
|
Data string `json:"data,omitempty"`
|
|
Encoding string `json:"encoding,omitempty"`
|
|
Rows *int `json:"rows,omitempty"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type terminalWebSocketServerFrame struct {
|
|
Data string `json:"data,omitempty"`
|
|
Error *string `json:"error,omitempty"`
|
|
FromSeq *int64 `json:"fromSeq,omitempty"`
|
|
Seq *int64 `json:"seq,omitempty"`
|
|
SessionID string `json:"sessionId"`
|
|
Status workspaceservice.TerminalStatus `json:"status,omitempty"`
|
|
ToSeq *int64 `json:"toSeq,omitempty"`
|
|
Type workspaceservice.TerminalStreamEventType `json:"type"`
|
|
Code *int `json:"code,omitempty"`
|
|
Signal *string `json:"signal,omitempty"`
|
|
Cwd *string `json:"cwd,omitempty"`
|
|
ProfileID *string `json:"profileId,omitempty"`
|
|
RuntimeKind *string `json:"runtimeKind,omitempty"`
|
|
Title *string `json:"title,omitempty"`
|
|
}
|
|
|
|
func (routes daemonRoutes) AttachWorkspaceTerminalWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
routes.api.attachWorkspaceTerminalWebSocket(w, r)
|
|
}
|
|
|
|
func (api DaemonAPI) attachWorkspaceTerminalWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
if api.TerminalService == nil {
|
|
writeTerminalWebSocketError(
|
|
w,
|
|
apierrors.WorkspaceTerminalServiceUnavailable(
|
|
apierrors.WithDeveloperMessage("workspace terminal service is unavailable"),
|
|
),
|
|
)
|
|
return
|
|
}
|
|
|
|
workspaceID := r.PathValue("workspaceID")
|
|
terminalID := r.PathValue("terminalID")
|
|
var afterSeq *int64
|
|
if rawAfterSeq := r.URL.Query().Get("afterSeq"); rawAfterSeq != "" {
|
|
parsed, err := strconv.ParseInt(rawAfterSeq, 10, 64)
|
|
if err != nil {
|
|
writeTerminalWebSocketError(
|
|
w,
|
|
apierrors.MalformedRequest(apierrors.WithDeveloperMessage("afterSeq must be an integer")),
|
|
)
|
|
return
|
|
}
|
|
afterSeq = &parsed
|
|
}
|
|
|
|
stream, err := api.TerminalService.AttachStream(r.Context(), workspaceID, terminalID, workspaceservice.AttachTerminalInput{
|
|
AfterSeq: afterSeq,
|
|
})
|
|
if err != nil {
|
|
writeTerminalWebSocketError(w, apierrors.Classify(err))
|
|
return
|
|
}
|
|
defer stream.Close()
|
|
|
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
|
InsecureSkipVerify: true,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "terminal detached")
|
|
|
|
ctx, cancel := context.WithCancel(r.Context())
|
|
defer cancel()
|
|
|
|
writeErr := make(chan error, 1)
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
writeErr <- nil
|
|
return
|
|
case event := <-stream.Events:
|
|
frame := terminalWebSocketServerFrame{
|
|
Data: event.Data,
|
|
Error: event.Error,
|
|
FromSeq: event.FromSeq,
|
|
Seq: event.Seq,
|
|
SessionID: event.SessionID,
|
|
Status: event.Status,
|
|
ToSeq: event.ToSeq,
|
|
Type: event.Type,
|
|
Code: event.Code,
|
|
Signal: event.Signal,
|
|
Cwd: event.Cwd,
|
|
ProfileID: event.ProfileID,
|
|
RuntimeKind: event.RuntimeKind,
|
|
Title: event.Title,
|
|
}
|
|
payload, err := json.Marshal(frame)
|
|
if err != nil {
|
|
writeErr <- err
|
|
return
|
|
}
|
|
if err := conn.Write(ctx, websocket.MessageText, payload); err != nil {
|
|
writeErr <- err
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case err := <-writeErr:
|
|
if err != nil {
|
|
_ = conn.Close(websocket.StatusInternalError, err.Error())
|
|
}
|
|
return
|
|
default:
|
|
}
|
|
|
|
messageType, payload, err := conn.Read(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if messageType != websocket.MessageText && messageType != websocket.MessageBinary {
|
|
continue
|
|
}
|
|
var frame terminalWebSocketClientFrame
|
|
if err := json.Unmarshal(payload, &frame); err != nil {
|
|
_ = conn.Close(websocket.StatusInvalidFramePayloadData, "invalid terminal frame")
|
|
return
|
|
}
|
|
switch frame.Type {
|
|
case "input":
|
|
payload := frame.Data
|
|
if frame.Encoding == "binary" {
|
|
decoded, err := base64.StdEncoding.DecodeString(frame.Data)
|
|
if err != nil {
|
|
_ = conn.Close(websocket.StatusInvalidFramePayloadData, "binary terminal input must be base64 encoded")
|
|
return
|
|
}
|
|
payload = string(decoded)
|
|
}
|
|
if err := api.TerminalService.Write(ctx, workspaceID, terminalID, payload); err != nil {
|
|
_ = conn.Close(websocket.StatusInternalError, err.Error())
|
|
return
|
|
}
|
|
case "resize":
|
|
if frame.Cols == nil || frame.Rows == nil {
|
|
_ = conn.Close(websocket.StatusInvalidFramePayloadData, "resize requires cols and rows")
|
|
return
|
|
}
|
|
if _, err := api.TerminalService.Resize(ctx, workspaceID, terminalID, workspaceservice.ResizeTerminalInput{
|
|
Cols: *frame.Cols,
|
|
Rows: *frame.Rows,
|
|
}); err != nil {
|
|
_ = conn.Close(websocket.StatusInternalError, err.Error())
|
|
return
|
|
}
|
|
case "detach":
|
|
return
|
|
case "ping":
|
|
continue
|
|
default:
|
|
_ = conn.Close(websocket.StatusInvalidFramePayloadData, "unknown terminal frame")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeTerminalWebSocketError(w http.ResponseWriter, err *apierrors.ProtocolError) {
|
|
if err == nil {
|
|
err = apierrors.WorkspaceOperationFailed()
|
|
}
|
|
tuttitypes.WriteError(
|
|
w,
|
|
err.StatusCode,
|
|
string(err.Code),
|
|
err.Reason,
|
|
err.DeveloperMessage,
|
|
)
|
|
}
|
|
|
|
func generatedTerminalSessions(sessions []workspaceservice.TerminalSession) []tuttigenerated.WorkspaceTerminalSession {
|
|
result := make([]tuttigenerated.WorkspaceTerminalSession, 0, len(sessions))
|
|
for _, session := range sessions {
|
|
result = append(result, generatedTerminalSession(session))
|
|
}
|
|
return result
|
|
}
|
|
|
|
func generatedTerminalSession(session workspaceservice.TerminalSession) tuttigenerated.WorkspaceTerminalSession {
|
|
return tuttigenerated.WorkspaceTerminalSession{
|
|
Cols: session.Cols,
|
|
CreatedAt: session.CreatedAt,
|
|
Cwd: session.Cwd,
|
|
EndedAt: session.EndedAt,
|
|
Id: session.ID,
|
|
LastError: session.LastError,
|
|
ProfileId: session.ProfileID,
|
|
Rows: session.Rows,
|
|
RuntimeKind: tuttigenerated.Local,
|
|
Status: generatedTerminalStatus(session.Status),
|
|
Title: session.Title,
|
|
UpdatedAt: session.UpdatedAt,
|
|
WorkspaceId: session.WorkspaceID,
|
|
}
|
|
}
|
|
|
|
func generatedTerminalStatus(status workspaceservice.TerminalStatus) tuttigenerated.WorkspaceTerminalStatus {
|
|
switch status {
|
|
case workspaceservice.TerminalStatusCreated:
|
|
return tuttigenerated.WorkspaceTerminalStatusCreated
|
|
case workspaceservice.TerminalStatusStarting:
|
|
return tuttigenerated.WorkspaceTerminalStatusStarting
|
|
case workspaceservice.TerminalStatusDetached:
|
|
return tuttigenerated.WorkspaceTerminalStatusDetached
|
|
case workspaceservice.TerminalStatusExited:
|
|
return tuttigenerated.WorkspaceTerminalStatusExited
|
|
case workspaceservice.TerminalStatusFailed:
|
|
return tuttigenerated.WorkspaceTerminalStatusFailed
|
|
default:
|
|
return tuttigenerated.WorkspaceTerminalStatusRunning
|
|
}
|
|
}
|
|
|
|
func generatedTerminalCloseGuard(guard workspaceservice.TerminalCloseGuard) tuttigenerated.WorkspaceTerminalCloseGuard {
|
|
return tuttigenerated.WorkspaceTerminalCloseGuard{
|
|
LeaderCommand: guard.LeaderCommand,
|
|
Reason: generatedTerminalCloseGuardReason(guard.Reason),
|
|
RequiresConfirmation: guard.RequiresConfirmation,
|
|
Status: generatedTerminalStatus(guard.Status),
|
|
}
|
|
}
|
|
|
|
func generatedTerminalCloseGuardReason(reason string) tuttigenerated.WorkspaceTerminalCloseGuardReason {
|
|
switch reason {
|
|
case "foreground-process":
|
|
return tuttigenerated.WorkspaceTerminalCloseGuardReasonForegroundProcess
|
|
case "not-running":
|
|
return tuttigenerated.WorkspaceTerminalCloseGuardReasonNotRunning
|
|
case "running":
|
|
return tuttigenerated.WorkspaceTerminalCloseGuardReasonRunning
|
|
default:
|
|
return tuttigenerated.WorkspaceTerminalCloseGuardReasonUnknown
|
|
}
|
|
}
|
|
|
|
func generatedTerminalSnapshot(snapshot workspaceservice.TerminalSnapshot) tuttigenerated.WorkspaceTerminalSnapshot {
|
|
return tuttigenerated.WorkspaceTerminalSnapshot{
|
|
Data: snapshot.Data,
|
|
FromSeq: snapshot.FromSeq,
|
|
ToSeq: snapshot.ToSeq,
|
|
Truncated: snapshot.Truncated,
|
|
UpdatedAt: snapshot.UpdatedAt,
|
|
}
|
|
}
|