Files
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

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,
}
}