diff --git a/Cargo.lock b/Cargo.lock index c936b824e..5361af655 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2627,6 +2627,7 @@ dependencies = [ "base64 0.22.1", "chrono", "dirs", + "futures-util", "globset", "ignore", "lopdf", @@ -2652,6 +2653,7 @@ dependencies = [ "tokio", "tokio-cron-scheduler", "tokio-stream", + "tokio-tungstenite", "tonic", "tonic-build", "uuid", diff --git a/crates/agent-gateway/embed.go b/crates/agent-gateway/embed.go index 93eb21e1b..fe1d98ef4 100644 --- a/crates/agent-gateway/embed.go +++ b/crates/agent-gateway/embed.go @@ -4,5 +4,9 @@ import "embed" // WebUIAssets contains the embedded WebUI build output served by the HTTP server. // -//go:embed web/dist +// The all: prefix is required because Vite may emit chunks whose names begin +// with "_" (for example, lodash's _baseFor chunk). Plain directory embeds +// silently exclude files and directories beginning with "." or "_". +// +//go:embed all:web/dist var WebUIAssets embed.FS diff --git a/crates/agent-gateway/embed_test.go b/crates/agent-gateway/embed_test.go new file mode 100644 index 000000000..1596c6788 --- /dev/null +++ b/crates/agent-gateway/embed_test.go @@ -0,0 +1,56 @@ +package gateway + +import ( + "io/fs" + "os" + "sort" + "testing" +) + +func TestWebUIAssetsIncludeEntireDistTree(t *testing.T) { + diskFiles := regularFileSizes(t, os.DirFS("."), "web/dist") + embeddedFiles := regularFileSizes(t, WebUIAssets, "web/dist") + + var missing []string + for file, size := range diskFiles { + embeddedSize, ok := embeddedFiles[file] + if !ok { + missing = append(missing, file) + continue + } + if embeddedSize != size { + t.Fatalf("embedded WebUI asset %q size = %d, want %d", file, embeddedSize, size) + } + } + + if len(missing) > 0 { + sort.Strings(missing) + t.Fatalf("embedded WebUI assets are missing files from web/dist: %v", missing) + } +} + +func regularFileSizes(t *testing.T, fileSystem fs.FS, root string) map[string]int64 { + t.Helper() + + files := make(map[string]int64) + err := fs.WalkDir(fileSystem, root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if info.Mode().IsRegular() { + files[path] = info.Size() + } + return nil + }) + if err != nil { + t.Fatalf("walk %q: %v", root, err) + } + return files +} diff --git a/crates/agent-gateway/internal/config/config.go b/crates/agent-gateway/internal/config/config.go index ad699a7de..eedf50bbb 100644 --- a/crates/agent-gateway/internal/config/config.go +++ b/crates/agent-gateway/internal/config/config.go @@ -17,6 +17,8 @@ type Config struct { TLSCert string TLSKey string RequestTimeout time.Duration + ChatStartTimeout time.Duration + ChatRenderStartTimeout time.Duration HeartbeatPeriod time.Duration WebSocketHeartbeatPeriod time.Duration WebSocketWriteTimeout time.Duration @@ -32,6 +34,8 @@ func Load() *Config { flag.StringVar(&cfg.TLSCert, "tls-cert", getenv("LIVEAGENT_GATEWAY_TLS_CERT", ""), "TLS certificate path") flag.StringVar(&cfg.TLSKey, "tls-key", getenv("LIVEAGENT_GATEWAY_TLS_KEY", ""), "TLS private key path") flag.DurationVar(&cfg.RequestTimeout, "request-timeout", getenvDuration("LIVEAGENT_GATEWAY_REQUEST_TIMEOUT", 2*time.Minute), "request timeout for non-streaming API calls") + flag.DurationVar(&cfg.ChatStartTimeout, "chat-start-timeout", getenvDuration("LIVEAGENT_GATEWAY_CHAT_START_TIMEOUT", 15*time.Second), "timeout waiting for the desktop backend to accept a remote chat request") + flag.DurationVar(&cfg.ChatRenderStartTimeout, "chat-render-start-timeout", getenvDuration("LIVEAGENT_GATEWAY_CHAT_RENDER_START_TIMEOUT", 45*time.Second), "timeout waiting for the desktop app to start an accepted remote chat request") flag.DurationVar(&cfg.HeartbeatPeriod, "heartbeat-period", getenvDuration("LIVEAGENT_GATEWAY_HEARTBEAT_PERIOD", 30*time.Second), "ping interval for agent connection") flag.DurationVar(&cfg.WebSocketHeartbeatPeriod, "websocket-heartbeat-period", getenvDuration("LIVEAGENT_GATEWAY_WS_HEARTBEAT_PERIOD", 15*time.Second), "ping interval for browser WebSocket connections") flag.DurationVar(&cfg.WebSocketWriteTimeout, "websocket-write-timeout", getenvDuration("LIVEAGENT_GATEWAY_WS_WRITE_TIMEOUT", 10*time.Second), "write timeout for browser WebSocket connections") @@ -49,6 +53,12 @@ func Load() *Config { if cfg.GRPCMaxMessageBytes <= 0 { cfg.GRPCMaxMessageBytes = DefaultGRPCMaxMessageBytes } + if cfg.ChatStartTimeout <= 0 { + cfg.ChatStartTimeout = 15 * time.Second + } + if cfg.ChatRenderStartTimeout <= 0 { + cfg.ChatRenderStartTimeout = 45 * time.Second + } if cfg.WebSocketHeartbeatPeriod <= 0 { cfg.WebSocketHeartbeatPeriod = 15 * time.Second } diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index 0d65fb222..7c159763e 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway.pb.go @@ -21,6 +21,82 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type TunnelFrameKind int32 + +const ( + TunnelFrameKind_TUNNEL_FRAME_KIND_UNSPECIFIED TunnelFrameKind = 0 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START TunnelFrameKind = 1 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY TunnelFrameKind = 2 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_END TunnelFrameKind = 3 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_START TunnelFrameKind = 4 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY TunnelFrameKind = 5 + TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END TunnelFrameKind = 6 + TunnelFrameKind_TUNNEL_FRAME_KIND_WS_OPEN TunnelFrameKind = 7 + TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME TunnelFrameKind = 8 + TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE TunnelFrameKind = 9 + TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR TunnelFrameKind = 10 + TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL TunnelFrameKind = 11 +) + +// Enum value maps for TunnelFrameKind. +var ( + TunnelFrameKind_name = map[int32]string{ + 0: "TUNNEL_FRAME_KIND_UNSPECIFIED", + 1: "TUNNEL_FRAME_KIND_HTTP_REQUEST_START", + 2: "TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY", + 3: "TUNNEL_FRAME_KIND_HTTP_REQUEST_END", + 4: "TUNNEL_FRAME_KIND_HTTP_RESPONSE_START", + 5: "TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY", + 6: "TUNNEL_FRAME_KIND_HTTP_RESPONSE_END", + 7: "TUNNEL_FRAME_KIND_WS_OPEN", + 8: "TUNNEL_FRAME_KIND_WS_FRAME", + 9: "TUNNEL_FRAME_KIND_WS_CLOSE", + 10: "TUNNEL_FRAME_KIND_ERROR", + 11: "TUNNEL_FRAME_KIND_CANCEL", + } + TunnelFrameKind_value = map[string]int32{ + "TUNNEL_FRAME_KIND_UNSPECIFIED": 0, + "TUNNEL_FRAME_KIND_HTTP_REQUEST_START": 1, + "TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY": 2, + "TUNNEL_FRAME_KIND_HTTP_REQUEST_END": 3, + "TUNNEL_FRAME_KIND_HTTP_RESPONSE_START": 4, + "TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY": 5, + "TUNNEL_FRAME_KIND_HTTP_RESPONSE_END": 6, + "TUNNEL_FRAME_KIND_WS_OPEN": 7, + "TUNNEL_FRAME_KIND_WS_FRAME": 8, + "TUNNEL_FRAME_KIND_WS_CLOSE": 9, + "TUNNEL_FRAME_KIND_ERROR": 10, + "TUNNEL_FRAME_KIND_CANCEL": 11, + } +) + +func (x TunnelFrameKind) Enum() *TunnelFrameKind { + p := new(TunnelFrameKind) + *p = x + return p +} + +func (x TunnelFrameKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TunnelFrameKind) Descriptor() protoreflect.EnumDescriptor { + return file_proto_v1_gateway_proto_enumTypes[0].Descriptor() +} + +func (TunnelFrameKind) Type() protoreflect.EnumType { + return &file_proto_v1_gateway_proto_enumTypes[0] +} + +func (x TunnelFrameKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TunnelFrameKind.Descriptor instead. +func (TunnelFrameKind) EnumDescriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{0} +} + type ChatEvent_ChatEventType int32 const ( @@ -69,11 +145,11 @@ func (x ChatEvent_ChatEventType) String() string { } func (ChatEvent_ChatEventType) Descriptor() protoreflect.EnumDescriptor { - return file_proto_v1_gateway_proto_enumTypes[0].Descriptor() + return file_proto_v1_gateway_proto_enumTypes[1].Descriptor() } func (ChatEvent_ChatEventType) Type() protoreflect.EnumType { - return &file_proto_v1_gateway_proto_enumTypes[0] + return &file_proto_v1_gateway_proto_enumTypes[1] } func (x ChatEvent_ChatEventType) Number() protoreflect.EnumNumber { @@ -82,7 +158,7 @@ func (x ChatEvent_ChatEventType) Number() protoreflect.EnumNumber { // Deprecated: Use ChatEvent_ChatEventType.Descriptor instead. func (ChatEvent_ChatEventType) EnumDescriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{23, 0} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{28, 0} } type AuthRequest struct { @@ -248,6 +324,9 @@ type GatewayEnvelope struct { // *GatewayEnvelope_GitRequest // *GatewayEnvelope_FsReadEditableText // *GatewayEnvelope_FsReadWorkspaceImage + // *GatewayEnvelope_TunnelControl + // *GatewayEnvelope_TunnelControlResp + // *GatewayEnvelope_TunnelFrame Payload isGatewayEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -637,6 +716,33 @@ func (x *GatewayEnvelope) GetFsReadWorkspaceImage() *FsReadWorkspaceImageRequest return nil } +func (x *GatewayEnvelope) GetTunnelControl() *TunnelControlRequest { + if x != nil { + if x, ok := x.Payload.(*GatewayEnvelope_TunnelControl); ok { + return x.TunnelControl + } + } + return nil +} + +func (x *GatewayEnvelope) GetTunnelControlResp() *TunnelControlResponse { + if x != nil { + if x, ok := x.Payload.(*GatewayEnvelope_TunnelControlResp); ok { + return x.TunnelControlResp + } + } + return nil +} + +func (x *GatewayEnvelope) GetTunnelFrame() *TunnelFrame { + if x != nil { + if x, ok := x.Payload.(*GatewayEnvelope_TunnelFrame); ok { + return x.TunnelFrame + } + } + return nil +} + type isGatewayEnvelope_Payload interface { isGatewayEnvelope_Payload() } @@ -789,6 +895,18 @@ type GatewayEnvelope_FsReadWorkspaceImage struct { FsReadWorkspaceImage *FsReadWorkspaceImageRequest `protobuf:"bytes,63,opt,name=fs_read_workspace_image,json=fsReadWorkspaceImage,proto3,oneof"` } +type GatewayEnvelope_TunnelControl struct { + TunnelControl *TunnelControlRequest `protobuf:"bytes,67,opt,name=tunnel_control,json=tunnelControl,proto3,oneof"` +} + +type GatewayEnvelope_TunnelControlResp struct { + TunnelControlResp *TunnelControlResponse `protobuf:"bytes,68,opt,name=tunnel_control_resp,json=tunnelControlResp,proto3,oneof"` +} + +type GatewayEnvelope_TunnelFrame struct { + TunnelFrame *TunnelFrame `protobuf:"bytes,69,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` +} + func (*GatewayEnvelope_ChatRequest) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_CancelChat) isGatewayEnvelope_Payload() {} @@ -863,6 +981,12 @@ func (*GatewayEnvelope_FsReadEditableText) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_FsReadWorkspaceImage) isGatewayEnvelope_Payload() {} +func (*GatewayEnvelope_TunnelControl) isGatewayEnvelope_Payload() {} + +func (*GatewayEnvelope_TunnelControlResp) isGatewayEnvelope_Payload() {} + +func (*GatewayEnvelope_TunnelFrame) isGatewayEnvelope_Payload() {} + type AgentEnvelope struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -908,6 +1032,11 @@ type AgentEnvelope struct { // *AgentEnvelope_GitResponse // *AgentEnvelope_FsReadEditableTextResp // *AgentEnvelope_FsReadWorkspaceImageResp + // *AgentEnvelope_TunnelControl + // *AgentEnvelope_TunnelControlResp + // *AgentEnvelope_TunnelFrame + // *AgentEnvelope_ChatControl + // *AgentEnvelope_RuntimeStatus // *AgentEnvelope_Error Payload isAgentEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields @@ -1316,6 +1445,51 @@ func (x *AgentEnvelope) GetFsReadWorkspaceImageResp() *FsReadWorkspaceImageRespo return nil } +func (x *AgentEnvelope) GetTunnelControl() *TunnelControlRequest { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_TunnelControl); ok { + return x.TunnelControl + } + } + return nil +} + +func (x *AgentEnvelope) GetTunnelControlResp() *TunnelControlResponse { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_TunnelControlResp); ok { + return x.TunnelControlResp + } + } + return nil +} + +func (x *AgentEnvelope) GetTunnelFrame() *TunnelFrame { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_TunnelFrame); ok { + return x.TunnelFrame + } + } + return nil +} + +func (x *AgentEnvelope) GetChatControl() *ChatControlEvent { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_ChatControl); ok { + return x.ChatControl + } + } + return nil +} + +func (x *AgentEnvelope) GetRuntimeStatus() *RuntimeStatusEvent { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_RuntimeStatus); ok { + return x.RuntimeStatus + } + } + return nil +} + func (x *AgentEnvelope) GetError() *ErrorResponse { if x != nil { if x, ok := x.Payload.(*AgentEnvelope_Error); ok { @@ -1485,6 +1659,26 @@ type AgentEnvelope_FsReadWorkspaceImageResp struct { FsReadWorkspaceImageResp *FsReadWorkspaceImageResponse `protobuf:"bytes,66,opt,name=fs_read_workspace_image_resp,json=fsReadWorkspaceImageResp,proto3,oneof"` } +type AgentEnvelope_TunnelControl struct { + TunnelControl *TunnelControlRequest `protobuf:"bytes,67,opt,name=tunnel_control,json=tunnelControl,proto3,oneof"` +} + +type AgentEnvelope_TunnelControlResp struct { + TunnelControlResp *TunnelControlResponse `protobuf:"bytes,68,opt,name=tunnel_control_resp,json=tunnelControlResp,proto3,oneof"` +} + +type AgentEnvelope_TunnelFrame struct { + TunnelFrame *TunnelFrame `protobuf:"bytes,69,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` +} + +type AgentEnvelope_ChatControl struct { + ChatControl *ChatControlEvent `protobuf:"bytes,70,opt,name=chat_control,json=chatControl,proto3,oneof"` +} + +type AgentEnvelope_RuntimeStatus struct { + RuntimeStatus *RuntimeStatusEvent `protobuf:"bytes,71,opt,name=runtime_status,json=runtimeStatus,proto3,oneof"` +} + type AgentEnvelope_Error struct { Error *ErrorResponse `protobuf:"bytes,99,opt,name=error,proto3,oneof"` } @@ -1567,6 +1761,16 @@ func (*AgentEnvelope_FsReadEditableTextResp) isAgentEnvelope_Payload() {} func (*AgentEnvelope_FsReadWorkspaceImageResp) isAgentEnvelope_Payload() {} +func (*AgentEnvelope_TunnelControl) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_TunnelControlResp) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_TunnelFrame) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_ChatControl) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_RuntimeStatus) isAgentEnvelope_Payload() {} + func (*AgentEnvelope_Error) isAgentEnvelope_Payload() {} type ChatSelectedModel struct { @@ -2033,28 +2237,36 @@ func (x *UploadedImagePreviewResponse) GetData() string { return "" } -type MemoryManageRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` - ArgsJson string `protobuf:"bytes,2,opt,name=args_json,json=argsJson,proto3" json:"args_json,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +type TunnelControlRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` + TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` + Slug string `protobuf:"bytes,3,opt,name=slug,proto3" json:"slug,omitempty"` + TargetUrl string `protobuf:"bytes,4,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` + Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` + TtlSeconds uint32 `protobuf:"varint,6,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` + ExpiresAt int64 `protobuf:"varint,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + PublicUrl string `protobuf:"bytes,8,opt,name=public_url,json=publicUrl,proto3" json:"public_url,omitempty"` + PublicBaseUrl string `protobuf:"bytes,9,opt,name=public_base_url,json=publicBaseUrl,proto3" json:"public_base_url,omitempty"` + ProjectPathKey string `protobuf:"bytes,10,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *MemoryManageRequest) Reset() { - *x = MemoryManageRequest{} +func (x *TunnelControlRequest) Reset() { + *x = TunnelControlRequest{} mi := &file_proto_v1_gateway_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *MemoryManageRequest) String() string { +func (x *TunnelControlRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*MemoryManageRequest) ProtoMessage() {} +func (*TunnelControlRequest) ProtoMessage() {} -func (x *MemoryManageRequest) ProtoReflect() protoreflect.Message { +func (x *TunnelControlRequest) ProtoReflect() protoreflect.Message { mi := &file_proto_v1_gateway_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -2066,46 +2278,106 @@ func (x *MemoryManageRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use MemoryManageRequest.ProtoReflect.Descriptor instead. -func (*MemoryManageRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use TunnelControlRequest.ProtoReflect.Descriptor instead. +func (*TunnelControlRequest) Descriptor() ([]byte, []int) { return file_proto_v1_gateway_proto_rawDescGZIP(), []int{12} } -func (x *MemoryManageRequest) GetCommand() string { +func (x *TunnelControlRequest) GetAction() string { if x != nil { - return x.Command + return x.Action } return "" } -func (x *MemoryManageRequest) GetArgsJson() string { +func (x *TunnelControlRequest) GetTunnelId() string { if x != nil { - return x.ArgsJson + return x.TunnelId } return "" } -type MemoryManageResponse struct { +func (x *TunnelControlRequest) GetSlug() string { + if x != nil { + return x.Slug + } + return "" +} + +func (x *TunnelControlRequest) GetTargetUrl() string { + if x != nil { + return x.TargetUrl + } + return "" +} + +func (x *TunnelControlRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TunnelControlRequest) GetTtlSeconds() uint32 { + if x != nil { + return x.TtlSeconds + } + return 0 +} + +func (x *TunnelControlRequest) GetExpiresAt() int64 { + if x != nil { + return x.ExpiresAt + } + return 0 +} + +func (x *TunnelControlRequest) GetPublicUrl() string { + if x != nil { + return x.PublicUrl + } + return "" +} + +func (x *TunnelControlRequest) GetPublicBaseUrl() string { + if x != nil { + return x.PublicBaseUrl + } + return "" +} + +func (x *TunnelControlRequest) GetProjectPathKey() string { + if x != nil { + return x.ProjectPathKey + } + return "" +} + +type TunnelControlResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - ResultJson string `protobuf:"bytes,1,opt,name=result_json,json=resultJson,proto3" json:"result_json,omitempty"` + Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` + Tunnels []*TunnelSummary `protobuf:"bytes,2,rep,name=tunnels,proto3" json:"tunnels,omitempty"` + Tunnel *TunnelSummary `protobuf:"bytes,3,opt,name=tunnel,proto3" json:"tunnel,omitempty"` + ErrorCode string `protobuf:"bytes,4,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + ErrorMessage string `protobuf:"bytes,5,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *MemoryManageResponse) Reset() { - *x = MemoryManageResponse{} +func (x *TunnelControlResponse) Reset() { + *x = TunnelControlResponse{} mi := &file_proto_v1_gateway_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *MemoryManageResponse) String() string { +func (x *TunnelControlResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*MemoryManageResponse) ProtoMessage() {} +func (*TunnelControlResponse) ProtoMessage() {} -func (x *MemoryManageResponse) ProtoReflect() protoreflect.Message { +func (x *TunnelControlResponse) ProtoReflect() protoreflect.Message { mi := &file_proto_v1_gateway_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -2117,48 +2389,76 @@ func (x *MemoryManageResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use MemoryManageResponse.ProtoReflect.Descriptor instead. -func (*MemoryManageResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use TunnelControlResponse.ProtoReflect.Descriptor instead. +func (*TunnelControlResponse) Descriptor() ([]byte, []int) { return file_proto_v1_gateway_proto_rawDescGZIP(), []int{13} } -func (x *MemoryManageResponse) GetResultJson() string { +func (x *TunnelControlResponse) GetAction() string { if x != nil { - return x.ResultJson + return x.Action } return "" } -type TerminalRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` - SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` - ProjectPathKey string `protobuf:"bytes,3,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` - Cwd string `protobuf:"bytes,4,opt,name=cwd,proto3" json:"cwd,omitempty"` - Shell string `protobuf:"bytes,5,opt,name=shell,proto3" json:"shell,omitempty"` - Title string `protobuf:"bytes,6,opt,name=title,proto3" json:"title,omitempty"` - Data string `protobuf:"bytes,7,opt,name=data,proto3" json:"data,omitempty"` - Cols uint32 `protobuf:"varint,8,opt,name=cols,proto3" json:"cols,omitempty"` - Rows uint32 `protobuf:"varint,9,opt,name=rows,proto3" json:"rows,omitempty"` - MaxBytes uint32 `protobuf:"varint,10,opt,name=max_bytes,json=maxBytes,proto3" json:"max_bytes,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +func (x *TunnelControlResponse) GetTunnels() []*TunnelSummary { + if x != nil { + return x.Tunnels + } + return nil } -func (x *TerminalRequest) Reset() { - *x = TerminalRequest{} +func (x *TunnelControlResponse) GetTunnel() *TunnelSummary { + if x != nil { + return x.Tunnel + } + return nil +} + +func (x *TunnelControlResponse) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *TunnelControlResponse) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +type TunnelSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + TargetUrl string `protobuf:"bytes,4,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` + PublicUrl string `protobuf:"bytes,5,opt,name=public_url,json=publicUrl,proto3" json:"public_url,omitempty"` + CreatedAt int64 `protobuf:"varint,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + ExpiresAt int64 `protobuf:"varint,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + ActiveConnections uint32 `protobuf:"varint,8,opt,name=active_connections,json=activeConnections,proto3" json:"active_connections,omitempty"` + Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"` + ProjectPathKey string `protobuf:"bytes,10,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelSummary) Reset() { + *x = TunnelSummary{} mi := &file_proto_v1_gateway_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *TerminalRequest) String() string { +func (x *TunnelSummary) String() string { return protoimpl.X.MessageStringOf(x) } -func (*TerminalRequest) ProtoMessage() {} +func (*TunnelSummary) ProtoMessage() {} -func (x *TerminalRequest) ProtoReflect() protoreflect.Message { +func (x *TunnelSummary) ProtoReflect() protoreflect.Message { mi := &file_proto_v1_gateway_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -2170,83 +2470,479 @@ func (x *TerminalRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use TerminalRequest.ProtoReflect.Descriptor instead. -func (*TerminalRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use TunnelSummary.ProtoReflect.Descriptor instead. +func (*TunnelSummary) Descriptor() ([]byte, []int) { return file_proto_v1_gateway_proto_rawDescGZIP(), []int{14} } -func (x *TerminalRequest) GetAction() string { +func (x *TunnelSummary) GetId() string { if x != nil { - return x.Action + return x.Id } return "" } -func (x *TerminalRequest) GetSessionId() string { +func (x *TunnelSummary) GetSlug() string { if x != nil { - return x.SessionId + return x.Slug } return "" } -func (x *TerminalRequest) GetProjectPathKey() string { +func (x *TunnelSummary) GetName() string { if x != nil { - return x.ProjectPathKey + return x.Name } return "" } -func (x *TerminalRequest) GetCwd() string { +func (x *TunnelSummary) GetTargetUrl() string { if x != nil { - return x.Cwd + return x.TargetUrl } return "" } -func (x *TerminalRequest) GetShell() string { +func (x *TunnelSummary) GetPublicUrl() string { if x != nil { - return x.Shell + return x.PublicUrl } return "" } -func (x *TerminalRequest) GetTitle() string { +func (x *TunnelSummary) GetCreatedAt() int64 { if x != nil { - return x.Title + return x.CreatedAt } - return "" + return 0 } -func (x *TerminalRequest) GetData() string { +func (x *TunnelSummary) GetExpiresAt() int64 { if x != nil { - return x.Data + return x.ExpiresAt } - return "" + return 0 } -func (x *TerminalRequest) GetCols() uint32 { +func (x *TunnelSummary) GetActiveConnections() uint32 { if x != nil { - return x.Cols + return x.ActiveConnections } return 0 } -func (x *TerminalRequest) GetRows() uint32 { +func (x *TunnelSummary) GetStatus() string { if x != nil { - return x.Rows + return x.Status } - return 0 + return "" } -func (x *TerminalRequest) GetMaxBytes() uint32 { +func (x *TunnelSummary) GetProjectPathKey() string { if x != nil { - return x.MaxBytes + return x.ProjectPathKey } - return 0 + return "" } -type TerminalSession struct { - state protoimpl.MessageState `protogen:"open.v1"` +type TunnelHeader struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelHeader) Reset() { + *x = TunnelHeader{} + mi := &file_proto_v1_gateway_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelHeader) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelHeader) ProtoMessage() {} + +func (x *TunnelHeader) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelHeader.ProtoReflect.Descriptor instead. +func (*TunnelHeader) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{15} +} + +func (x *TunnelHeader) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TunnelHeader) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type TunnelFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` + TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` + Slug string `protobuf:"bytes,3,opt,name=slug,proto3" json:"slug,omitempty"` + Kind TunnelFrameKind `protobuf:"varint,4,opt,name=kind,proto3,enum=liveagent.gateway.v1.TunnelFrameKind" json:"kind,omitempty"` + Method string `protobuf:"bytes,5,opt,name=method,proto3" json:"method,omitempty"` + Path string `protobuf:"bytes,6,opt,name=path,proto3" json:"path,omitempty"` + Headers []*TunnelHeader `protobuf:"bytes,7,rep,name=headers,proto3" json:"headers,omitempty"` + StatusCode uint32 `protobuf:"varint,8,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` + Body []byte `protobuf:"bytes,9,opt,name=body,proto3" json:"body,omitempty"` + EndStream bool `protobuf:"varint,10,opt,name=end_stream,json=endStream,proto3" json:"end_stream,omitempty"` + Error string `protobuf:"bytes,11,opt,name=error,proto3" json:"error,omitempty"` + WsMessageType string `protobuf:"bytes,12,opt,name=ws_message_type,json=wsMessageType,proto3" json:"ws_message_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelFrame) Reset() { + *x = TunnelFrame{} + mi := &file_proto_v1_gateway_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelFrame) ProtoMessage() {} + +func (x *TunnelFrame) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelFrame.ProtoReflect.Descriptor instead. +func (*TunnelFrame) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{16} +} + +func (x *TunnelFrame) GetStreamId() string { + if x != nil { + return x.StreamId + } + return "" +} + +func (x *TunnelFrame) GetTunnelId() string { + if x != nil { + return x.TunnelId + } + return "" +} + +func (x *TunnelFrame) GetSlug() string { + if x != nil { + return x.Slug + } + return "" +} + +func (x *TunnelFrame) GetKind() TunnelFrameKind { + if x != nil { + return x.Kind + } + return TunnelFrameKind_TUNNEL_FRAME_KIND_UNSPECIFIED +} + +func (x *TunnelFrame) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *TunnelFrame) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *TunnelFrame) GetHeaders() []*TunnelHeader { + if x != nil { + return x.Headers + } + return nil +} + +func (x *TunnelFrame) GetStatusCode() uint32 { + if x != nil { + return x.StatusCode + } + return 0 +} + +func (x *TunnelFrame) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *TunnelFrame) GetEndStream() bool { + if x != nil { + return x.EndStream + } + return false +} + +func (x *TunnelFrame) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *TunnelFrame) GetWsMessageType() string { + if x != nil { + return x.WsMessageType + } + return "" +} + +type MemoryManageRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` + ArgsJson string `protobuf:"bytes,2,opt,name=args_json,json=argsJson,proto3" json:"args_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryManageRequest) Reset() { + *x = MemoryManageRequest{} + mi := &file_proto_v1_gateway_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryManageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryManageRequest) ProtoMessage() {} + +func (x *MemoryManageRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MemoryManageRequest.ProtoReflect.Descriptor instead. +func (*MemoryManageRequest) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{17} +} + +func (x *MemoryManageRequest) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *MemoryManageRequest) GetArgsJson() string { + if x != nil { + return x.ArgsJson + } + return "" +} + +type MemoryManageResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ResultJson string `protobuf:"bytes,1,opt,name=result_json,json=resultJson,proto3" json:"result_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoryManageResponse) Reset() { + *x = MemoryManageResponse{} + mi := &file_proto_v1_gateway_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoryManageResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoryManageResponse) ProtoMessage() {} + +func (x *MemoryManageResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MemoryManageResponse.ProtoReflect.Descriptor instead. +func (*MemoryManageResponse) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{18} +} + +func (x *MemoryManageResponse) GetResultJson() string { + if x != nil { + return x.ResultJson + } + return "" +} + +type TerminalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + ProjectPathKey string `protobuf:"bytes,3,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + Cwd string `protobuf:"bytes,4,opt,name=cwd,proto3" json:"cwd,omitempty"` + Shell string `protobuf:"bytes,5,opt,name=shell,proto3" json:"shell,omitempty"` + Title string `protobuf:"bytes,6,opt,name=title,proto3" json:"title,omitempty"` + Data string `protobuf:"bytes,7,opt,name=data,proto3" json:"data,omitempty"` + Cols uint32 `protobuf:"varint,8,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,9,opt,name=rows,proto3" json:"rows,omitempty"` + MaxBytes uint32 `protobuf:"varint,10,opt,name=max_bytes,json=maxBytes,proto3" json:"max_bytes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TerminalRequest) Reset() { + *x = TerminalRequest{} + mi := &file_proto_v1_gateway_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TerminalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TerminalRequest) ProtoMessage() {} + +func (x *TerminalRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TerminalRequest.ProtoReflect.Descriptor instead. +func (*TerminalRequest) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{19} +} + +func (x *TerminalRequest) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +func (x *TerminalRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *TerminalRequest) GetProjectPathKey() string { + if x != nil { + return x.ProjectPathKey + } + return "" +} + +func (x *TerminalRequest) GetCwd() string { + if x != nil { + return x.Cwd + } + return "" +} + +func (x *TerminalRequest) GetShell() string { + if x != nil { + return x.Shell + } + return "" +} + +func (x *TerminalRequest) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *TerminalRequest) GetData() string { + if x != nil { + return x.Data + } + return "" +} + +func (x *TerminalRequest) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *TerminalRequest) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +func (x *TerminalRequest) GetMaxBytes() uint32 { + if x != nil { + return x.MaxBytes + } + return 0 +} + +type TerminalSession struct { + state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` ProjectPathKey string `protobuf:"bytes,2,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` Cwd string `protobuf:"bytes,3,opt,name=cwd,proto3" json:"cwd,omitempty"` @@ -2266,7 +2962,7 @@ type TerminalSession struct { func (x *TerminalSession) Reset() { *x = TerminalSession{} - mi := &file_proto_v1_gateway_proto_msgTypes[15] + mi := &file_proto_v1_gateway_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2278,7 +2974,7 @@ func (x *TerminalSession) String() string { func (*TerminalSession) ProtoMessage() {} func (x *TerminalSession) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[15] + mi := &file_proto_v1_gateway_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2291,7 +2987,7 @@ func (x *TerminalSession) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSession.ProtoReflect.Descriptor instead. func (*TerminalSession) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{15} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{20} } func (x *TerminalSession) GetId() string { @@ -2396,7 +3092,7 @@ type TerminalShellOption struct { func (x *TerminalShellOption) Reset() { *x = TerminalShellOption{} - mi := &file_proto_v1_gateway_proto_msgTypes[16] + mi := &file_proto_v1_gateway_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2408,7 +3104,7 @@ func (x *TerminalShellOption) String() string { func (*TerminalShellOption) ProtoMessage() {} func (x *TerminalShellOption) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[16] + mi := &file_proto_v1_gateway_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2421,7 +3117,7 @@ func (x *TerminalShellOption) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalShellOption.ProtoReflect.Descriptor instead. func (*TerminalShellOption) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{16} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{21} } func (x *TerminalShellOption) GetId() string { @@ -2462,7 +3158,7 @@ type TerminalResponse struct { func (x *TerminalResponse) Reset() { *x = TerminalResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[17] + mi := &file_proto_v1_gateway_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2474,7 +3170,7 @@ func (x *TerminalResponse) String() string { func (*TerminalResponse) ProtoMessage() {} func (x *TerminalResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[17] + mi := &file_proto_v1_gateway_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2487,7 +3183,7 @@ func (x *TerminalResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalResponse.ProtoReflect.Descriptor instead. func (*TerminalResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{17} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{22} } func (x *TerminalResponse) GetAction() string { @@ -2568,7 +3264,7 @@ type TerminalEvent struct { func (x *TerminalEvent) Reset() { *x = TerminalEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[18] + mi := &file_proto_v1_gateway_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2580,7 +3276,7 @@ func (x *TerminalEvent) String() string { func (*TerminalEvent) ProtoMessage() {} func (x *TerminalEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[18] + mi := &file_proto_v1_gateway_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2593,7 +3289,7 @@ func (x *TerminalEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalEvent.ProtoReflect.Descriptor instead. func (*TerminalEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{18} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{23} } func (x *TerminalEvent) GetKind() string { @@ -2656,7 +3352,7 @@ type GitRequest struct { func (x *GitRequest) Reset() { *x = GitRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[19] + mi := &file_proto_v1_gateway_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2668,7 +3364,7 @@ func (x *GitRequest) String() string { func (*GitRequest) ProtoMessage() {} func (x *GitRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[19] + mi := &file_proto_v1_gateway_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2681,7 +3377,7 @@ func (x *GitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GitRequest.ProtoReflect.Descriptor instead. func (*GitRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{19} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{24} } func (x *GitRequest) GetAction() string { @@ -2715,7 +3411,7 @@ type GitResponse struct { func (x *GitResponse) Reset() { *x = GitResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[20] + mi := &file_proto_v1_gateway_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2727,7 +3423,7 @@ func (x *GitResponse) String() string { func (*GitResponse) ProtoMessage() {} func (x *GitResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[20] + mi := &file_proto_v1_gateway_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2740,7 +3436,7 @@ func (x *GitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GitResponse.ProtoReflect.Descriptor instead. func (*GitResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{20} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{25} } func (x *GitResponse) GetAction() string { @@ -2774,7 +3470,7 @@ type ChatRequest struct { func (x *ChatRequest) Reset() { *x = ChatRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[21] + mi := &file_proto_v1_gateway_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2786,7 +3482,7 @@ func (x *ChatRequest) String() string { func (*ChatRequest) ProtoMessage() {} func (x *ChatRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[21] + mi := &file_proto_v1_gateway_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2799,94 +3495,310 @@ func (x *ChatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead. func (*ChatRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{21} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{26} +} + +func (x *ChatRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ChatRequest) GetSelectedModel() *ChatSelectedModel { + if x != nil { + return x.SelectedModel + } + return nil +} + +func (x *ChatRequest) GetExecutionMode() string { + if x != nil { + return x.ExecutionMode + } + return "" +} + +func (x *ChatRequest) GetWorkdir() string { + if x != nil { + return x.Workdir + } + return "" +} + +func (x *ChatRequest) GetSelectedSystemTools() []string { + if x != nil { + return x.SelectedSystemTools + } + return nil +} + +func (x *ChatRequest) GetUploadedFiles() []*ChatUploadedFile { + if x != nil { + return x.UploadedFiles + } + return nil +} + +func (x *ChatRequest) GetClientRequestId() string { + if x != nil { + return x.ClientRequestId + } + return "" +} + +func (x *ChatRequest) GetRuntimeControls() *ChatRuntimeControls { + if x != nil { + return x.RuntimeControls + } + return nil +} + +type CancelChatRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CancelChatRequest) Reset() { + *x = CancelChatRequest{} + mi := &file_proto_v1_gateway_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CancelChatRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelChatRequest) ProtoMessage() {} + +func (x *CancelChatRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelChatRequest.ProtoReflect.Descriptor instead. +func (*CancelChatRequest) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{27} +} + +func (x *CancelChatRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +type ChatEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type ChatEvent_ChatEventType `protobuf:"varint,1,opt,name=type,proto3,enum=liveagent.gateway.v1.ChatEvent_ChatEventType" json:"type,omitempty"` + ConversationId string `protobuf:"bytes,2,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + Data string `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatEvent) Reset() { + *x = ChatEvent{} + mi := &file_proto_v1_gateway_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatEvent) ProtoMessage() {} + +func (x *ChatEvent) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChatEvent.ProtoReflect.Descriptor instead. +func (*ChatEvent) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{28} +} + +func (x *ChatEvent) GetType() ChatEvent_ChatEventType { + if x != nil { + return x.Type + } + return ChatEvent_TOKEN +} + +func (x *ChatEvent) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatEvent) GetData() string { + if x != nil { + return x.Data + } + return "" +} + +type ChatControlEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + ClientRequestId string `protobuf:"bytes,2,opt,name=client_request_id,json=clientRequestId,proto3" json:"client_request_id,omitempty"` + ConversationId string `protobuf:"bytes,3,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + RunEpoch int64 `protobuf:"varint,4,opt,name=run_epoch,json=runEpoch,proto3" json:"run_epoch,omitempty"` + Type string `protobuf:"bytes,5,opt,name=type,proto3" json:"type,omitempty"` + State string `protobuf:"bytes,6,opt,name=state,proto3" json:"state,omitempty"` + ErrorCode string `protobuf:"bytes,7,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + Message string `protobuf:"bytes,8,opt,name=message,proto3" json:"message,omitempty"` + Seq int64 `protobuf:"varint,9,opt,name=seq,proto3" json:"seq,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatControlEvent) Reset() { + *x = ChatControlEvent{} + mi := &file_proto_v1_gateway_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatControlEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatControlEvent) ProtoMessage() {} + +func (x *ChatControlEvent) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChatControlEvent.ProtoReflect.Descriptor instead. +func (*ChatControlEvent) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{29} } -func (x *ChatRequest) GetConversationId() string { +func (x *ChatControlEvent) GetRequestId() string { if x != nil { - return x.ConversationId + return x.RequestId } return "" } -func (x *ChatRequest) GetMessage() string { +func (x *ChatControlEvent) GetClientRequestId() string { if x != nil { - return x.Message + return x.ClientRequestId } return "" } -func (x *ChatRequest) GetSelectedModel() *ChatSelectedModel { +func (x *ChatControlEvent) GetConversationId() string { if x != nil { - return x.SelectedModel + return x.ConversationId } - return nil + return "" } -func (x *ChatRequest) GetExecutionMode() string { +func (x *ChatControlEvent) GetRunEpoch() int64 { if x != nil { - return x.ExecutionMode + return x.RunEpoch } - return "" + return 0 } -func (x *ChatRequest) GetWorkdir() string { +func (x *ChatControlEvent) GetType() string { if x != nil { - return x.Workdir + return x.Type } return "" } -func (x *ChatRequest) GetSelectedSystemTools() []string { +func (x *ChatControlEvent) GetState() string { if x != nil { - return x.SelectedSystemTools + return x.State } - return nil + return "" } -func (x *ChatRequest) GetUploadedFiles() []*ChatUploadedFile { +func (x *ChatControlEvent) GetErrorCode() string { if x != nil { - return x.UploadedFiles + return x.ErrorCode } - return nil + return "" } -func (x *ChatRequest) GetClientRequestId() string { +func (x *ChatControlEvent) GetMessage() string { if x != nil { - return x.ClientRequestId + return x.Message } return "" } -func (x *ChatRequest) GetRuntimeControls() *ChatRuntimeControls { +func (x *ChatControlEvent) GetSeq() int64 { if x != nil { - return x.RuntimeControls + return x.Seq } - return nil + return 0 } -type CancelChatRequest struct { +type RuntimeStatusEvent struct { state protoimpl.MessageState `protogen:"open.v1"` - ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + WorkerId string `protobuf:"bytes,1,opt,name=worker_id,json=workerId,proto3" json:"worker_id,omitempty"` + State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + Visible bool `protobuf:"varint,3,opt,name=visible,proto3" json:"visible,omitempty"` + ActiveRunCount uint32 `protobuf:"varint,4,opt,name=active_run_count,json=activeRunCount,proto3" json:"active_run_count,omitempty"` + Timestamp int64 `protobuf:"varint,5,opt,name=timestamp,proto3" json:"timestamp,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *CancelChatRequest) Reset() { - *x = CancelChatRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[22] +func (x *RuntimeStatusEvent) Reset() { + *x = RuntimeStatusEvent{} + mi := &file_proto_v1_gateway_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *CancelChatRequest) String() string { +func (x *RuntimeStatusEvent) String() string { return protoimpl.X.MessageStringOf(x) } -func (*CancelChatRequest) ProtoMessage() {} +func (*RuntimeStatusEvent) ProtoMessage() {} -func (x *CancelChatRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[22] +func (x *RuntimeStatusEvent) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2897,76 +3809,44 @@ func (x *CancelChatRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use CancelChatRequest.ProtoReflect.Descriptor instead. -func (*CancelChatRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{22} +// Deprecated: Use RuntimeStatusEvent.ProtoReflect.Descriptor instead. +func (*RuntimeStatusEvent) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{30} } -func (x *CancelChatRequest) GetConversationId() string { +func (x *RuntimeStatusEvent) GetWorkerId() string { if x != nil { - return x.ConversationId + return x.WorkerId } return "" } -type ChatEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - Type ChatEvent_ChatEventType `protobuf:"varint,1,opt,name=type,proto3,enum=liveagent.gateway.v1.ChatEvent_ChatEventType" json:"type,omitempty"` - ConversationId string `protobuf:"bytes,2,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` - Data string `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ChatEvent) Reset() { - *x = ChatEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[23] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ChatEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ChatEvent) ProtoMessage() {} - -func (x *ChatEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[23] +func (x *RuntimeStatusEvent) GetState() string { if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms + return x.State } - return mi.MessageOf(x) -} - -// Deprecated: Use ChatEvent.ProtoReflect.Descriptor instead. -func (*ChatEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{23} + return "" } -func (x *ChatEvent) GetType() ChatEvent_ChatEventType { +func (x *RuntimeStatusEvent) GetVisible() bool { if x != nil { - return x.Type + return x.Visible } - return ChatEvent_TOKEN + return false } -func (x *ChatEvent) GetConversationId() string { +func (x *RuntimeStatusEvent) GetActiveRunCount() uint32 { if x != nil { - return x.ConversationId + return x.ActiveRunCount } - return "" + return 0 } -func (x *ChatEvent) GetData() string { +func (x *RuntimeStatusEvent) GetTimestamp() int64 { if x != nil { - return x.Data + return x.Timestamp } - return "" + return 0 } type CronManageRequest struct { @@ -2980,7 +3860,7 @@ type CronManageRequest struct { func (x *CronManageRequest) Reset() { *x = CronManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[24] + mi := &file_proto_v1_gateway_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2992,7 +3872,7 @@ func (x *CronManageRequest) String() string { func (*CronManageRequest) ProtoMessage() {} func (x *CronManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[24] + mi := &file_proto_v1_gateway_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3005,7 +3885,7 @@ func (x *CronManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageRequest.ProtoReflect.Descriptor instead. func (*CronManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{24} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{31} } func (x *CronManageRequest) GetAction() string { @@ -3039,7 +3919,7 @@ type CronManageResponse struct { func (x *CronManageResponse) Reset() { *x = CronManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[25] + mi := &file_proto_v1_gateway_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3051,7 +3931,7 @@ func (x *CronManageResponse) String() string { func (*CronManageResponse) ProtoMessage() {} func (x *CronManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[25] + mi := &file_proto_v1_gateway_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3064,7 +3944,7 @@ func (x *CronManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageResponse.ProtoReflect.Descriptor instead. func (*CronManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{25} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{32} } func (x *CronManageResponse) GetAction() string { @@ -3093,7 +3973,7 @@ type HistoryListRequest struct { func (x *HistoryListRequest) Reset() { *x = HistoryListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[26] + mi := &file_proto_v1_gateway_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3105,7 +3985,7 @@ func (x *HistoryListRequest) String() string { func (*HistoryListRequest) ProtoMessage() {} func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[26] + mi := &file_proto_v1_gateway_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3118,7 +3998,7 @@ func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListRequest.ProtoReflect.Descriptor instead. func (*HistoryListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{26} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{33} } func (x *HistoryListRequest) GetPage() int32 { @@ -3159,7 +4039,7 @@ type HistoryListResponse struct { func (x *HistoryListResponse) Reset() { *x = HistoryListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[27] + mi := &file_proto_v1_gateway_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3171,7 +4051,7 @@ func (x *HistoryListResponse) String() string { func (*HistoryListResponse) ProtoMessage() {} func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[27] + mi := &file_proto_v1_gateway_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3184,7 +4064,7 @@ func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListResponse.ProtoReflect.Descriptor instead. func (*HistoryListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{27} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{34} } func (x *HistoryListResponse) GetConversations() []*ConversationSummary { @@ -3221,7 +4101,7 @@ type ConversationSummary struct { func (x *ConversationSummary) Reset() { *x = ConversationSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[28] + mi := &file_proto_v1_gateway_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3233,7 +4113,7 @@ func (x *ConversationSummary) String() string { func (*ConversationSummary) ProtoMessage() {} func (x *ConversationSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[28] + mi := &file_proto_v1_gateway_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3246,7 +4126,7 @@ func (x *ConversationSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use ConversationSummary.ProtoReflect.Descriptor instead. func (*ConversationSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{28} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{35} } func (x *ConversationSummary) GetId() string { @@ -3343,7 +4223,7 @@ type HistoryGetRequest struct { func (x *HistoryGetRequest) Reset() { *x = HistoryGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[29] + mi := &file_proto_v1_gateway_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3355,7 +4235,7 @@ func (x *HistoryGetRequest) String() string { func (*HistoryGetRequest) ProtoMessage() {} func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[29] + mi := &file_proto_v1_gateway_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3368,7 +4248,7 @@ func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetRequest.ProtoReflect.Descriptor instead. func (*HistoryGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{29} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{36} } func (x *HistoryGetRequest) GetConversationId() string { @@ -3399,7 +4279,7 @@ type HistoryGetResponse struct { func (x *HistoryGetResponse) Reset() { *x = HistoryGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[30] + mi := &file_proto_v1_gateway_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3411,7 +4291,7 @@ func (x *HistoryGetResponse) String() string { func (*HistoryGetResponse) ProtoMessage() {} func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[30] + mi := &file_proto_v1_gateway_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3424,7 +4304,7 @@ func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetResponse.ProtoReflect.Descriptor instead. func (*HistoryGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{30} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{37} } func (x *HistoryGetResponse) GetConversationId() string { @@ -3479,7 +4359,7 @@ type HistoryRenameRequest struct { func (x *HistoryRenameRequest) Reset() { *x = HistoryRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[31] + mi := &file_proto_v1_gateway_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3491,7 +4371,7 @@ func (x *HistoryRenameRequest) String() string { func (*HistoryRenameRequest) ProtoMessage() {} func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[31] + mi := &file_proto_v1_gateway_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3504,7 +4384,7 @@ func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameRequest.ProtoReflect.Descriptor instead. func (*HistoryRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{31} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{38} } func (x *HistoryRenameRequest) GetConversationId() string { @@ -3530,7 +4410,7 @@ type HistoryRenameResponse struct { func (x *HistoryRenameResponse) Reset() { *x = HistoryRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[32] + mi := &file_proto_v1_gateway_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3542,7 +4422,7 @@ func (x *HistoryRenameResponse) String() string { func (*HistoryRenameResponse) ProtoMessage() {} func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[32] + mi := &file_proto_v1_gateway_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3555,7 +4435,7 @@ func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameResponse.ProtoReflect.Descriptor instead. func (*HistoryRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{32} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{39} } func (x *HistoryRenameResponse) GetConversation() *ConversationSummary { @@ -3575,7 +4455,7 @@ type HistoryPinRequest struct { func (x *HistoryPinRequest) Reset() { *x = HistoryPinRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[33] + mi := &file_proto_v1_gateway_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3587,7 +4467,7 @@ func (x *HistoryPinRequest) String() string { func (*HistoryPinRequest) ProtoMessage() {} func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[33] + mi := &file_proto_v1_gateway_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3600,7 +4480,7 @@ func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinRequest.ProtoReflect.Descriptor instead. func (*HistoryPinRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{33} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{40} } func (x *HistoryPinRequest) GetConversationId() string { @@ -3626,7 +4506,7 @@ type HistoryPinResponse struct { func (x *HistoryPinResponse) Reset() { *x = HistoryPinResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[34] + mi := &file_proto_v1_gateway_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3638,7 +4518,7 @@ func (x *HistoryPinResponse) String() string { func (*HistoryPinResponse) ProtoMessage() {} func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[34] + mi := &file_proto_v1_gateway_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3651,7 +4531,7 @@ func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinResponse.ProtoReflect.Descriptor instead. func (*HistoryPinResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{34} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{41} } func (x *HistoryPinResponse) GetConversation() *ConversationSummary { @@ -3675,7 +4555,7 @@ type HistoryShareStatus struct { func (x *HistoryShareStatus) Reset() { *x = HistoryShareStatus{} - mi := &file_proto_v1_gateway_proto_msgTypes[35] + mi := &file_proto_v1_gateway_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3687,7 +4567,7 @@ func (x *HistoryShareStatus) String() string { func (*HistoryShareStatus) ProtoMessage() {} func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[35] + mi := &file_proto_v1_gateway_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3700,7 +4580,7 @@ func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareStatus.ProtoReflect.Descriptor instead. func (*HistoryShareStatus) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{35} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{42} } func (x *HistoryShareStatus) GetConversationId() string { @@ -3754,7 +4634,7 @@ type HistoryShareGetRequest struct { func (x *HistoryShareGetRequest) Reset() { *x = HistoryShareGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[36] + mi := &file_proto_v1_gateway_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3766,7 +4646,7 @@ func (x *HistoryShareGetRequest) String() string { func (*HistoryShareGetRequest) ProtoMessage() {} func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[36] + mi := &file_proto_v1_gateway_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3779,7 +4659,7 @@ func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{36} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{43} } func (x *HistoryShareGetRequest) GetConversationId() string { @@ -3798,7 +4678,7 @@ type HistoryShareGetResponse struct { func (x *HistoryShareGetResponse) Reset() { *x = HistoryShareGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[37] + mi := &file_proto_v1_gateway_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3810,7 +4690,7 @@ func (x *HistoryShareGetResponse) String() string { func (*HistoryShareGetResponse) ProtoMessage() {} func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[37] + mi := &file_proto_v1_gateway_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3823,7 +4703,7 @@ func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{37} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{44} } func (x *HistoryShareGetResponse) GetShare() *HistoryShareStatus { @@ -3844,7 +4724,7 @@ type HistoryShareSetRequest struct { func (x *HistoryShareSetRequest) Reset() { *x = HistoryShareSetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[38] + mi := &file_proto_v1_gateway_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3856,7 +4736,7 @@ func (x *HistoryShareSetRequest) String() string { func (*HistoryShareSetRequest) ProtoMessage() {} func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[38] + mi := &file_proto_v1_gateway_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3869,7 +4749,7 @@ func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareSetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{38} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{45} } func (x *HistoryShareSetRequest) GetConversationId() string { @@ -3902,7 +4782,7 @@ type HistoryShareSetResponse struct { func (x *HistoryShareSetResponse) Reset() { *x = HistoryShareSetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[39] + mi := &file_proto_v1_gateway_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3914,7 +4794,7 @@ func (x *HistoryShareSetResponse) String() string { func (*HistoryShareSetResponse) ProtoMessage() {} func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[39] + mi := &file_proto_v1_gateway_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3927,7 +4807,7 @@ func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareSetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{39} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{46} } func (x *HistoryShareSetResponse) GetShare() *HistoryShareStatus { @@ -3946,7 +4826,7 @@ type HistoryShareResolveRequest struct { func (x *HistoryShareResolveRequest) Reset() { *x = HistoryShareResolveRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[40] + mi := &file_proto_v1_gateway_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3958,7 +4838,7 @@ func (x *HistoryShareResolveRequest) String() string { func (*HistoryShareResolveRequest) ProtoMessage() {} func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[40] + mi := &file_proto_v1_gateway_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3971,7 +4851,7 @@ func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveRequest.ProtoReflect.Descriptor instead. func (*HistoryShareResolveRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{40} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{47} } func (x *HistoryShareResolveRequest) GetToken() string { @@ -3994,7 +4874,7 @@ type HistoryShareResolveResponse struct { func (x *HistoryShareResolveResponse) Reset() { *x = HistoryShareResolveResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[41] + mi := &file_proto_v1_gateway_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4006,7 +4886,7 @@ func (x *HistoryShareResolveResponse) String() string { func (*HistoryShareResolveResponse) ProtoMessage() {} func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[41] + mi := &file_proto_v1_gateway_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4019,7 +4899,7 @@ func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveResponse.ProtoReflect.Descriptor instead. func (*HistoryShareResolveResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{41} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{48} } func (x *HistoryShareResolveResponse) GetConversationId() string { @@ -4065,7 +4945,7 @@ type HistoryWorkdirsRequest struct { func (x *HistoryWorkdirsRequest) Reset() { *x = HistoryWorkdirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[42] + mi := &file_proto_v1_gateway_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4077,7 +4957,7 @@ func (x *HistoryWorkdirsRequest) String() string { func (*HistoryWorkdirsRequest) ProtoMessage() {} func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[42] + mi := &file_proto_v1_gateway_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4090,7 +4970,7 @@ func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsRequest.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{42} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{49} } type HistoryWorkdirSummary struct { @@ -4104,7 +4984,7 @@ type HistoryWorkdirSummary struct { func (x *HistoryWorkdirSummary) Reset() { *x = HistoryWorkdirSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[43] + mi := &file_proto_v1_gateway_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4116,7 +4996,7 @@ func (x *HistoryWorkdirSummary) String() string { func (*HistoryWorkdirSummary) ProtoMessage() {} func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[43] + mi := &file_proto_v1_gateway_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4129,7 +5009,7 @@ func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirSummary.ProtoReflect.Descriptor instead. func (*HistoryWorkdirSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{43} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{50} } func (x *HistoryWorkdirSummary) GetPath() string { @@ -4162,7 +5042,7 @@ type HistoryWorkdirsResponse struct { func (x *HistoryWorkdirsResponse) Reset() { *x = HistoryWorkdirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[44] + mi := &file_proto_v1_gateway_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4174,7 +5054,7 @@ func (x *HistoryWorkdirsResponse) String() string { func (*HistoryWorkdirsResponse) ProtoMessage() {} func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[44] + mi := &file_proto_v1_gateway_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4187,7 +5067,7 @@ func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsResponse.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{44} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{51} } func (x *HistoryWorkdirsResponse) GetWorkdirs() []*HistoryWorkdirSummary { @@ -4206,7 +5086,7 @@ type HistoryDeleteRequest struct { func (x *HistoryDeleteRequest) Reset() { *x = HistoryDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[45] + mi := &file_proto_v1_gateway_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4218,7 +5098,7 @@ func (x *HistoryDeleteRequest) String() string { func (*HistoryDeleteRequest) ProtoMessage() {} func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[45] + mi := &file_proto_v1_gateway_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4231,7 +5111,7 @@ func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteRequest.ProtoReflect.Descriptor instead. func (*HistoryDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{45} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{52} } func (x *HistoryDeleteRequest) GetConversationId() string { @@ -4249,7 +5129,7 @@ type HistoryDeleteResponse struct { func (x *HistoryDeleteResponse) Reset() { *x = HistoryDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[46] + mi := &file_proto_v1_gateway_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4261,7 +5141,7 @@ func (x *HistoryDeleteResponse) String() string { func (*HistoryDeleteResponse) ProtoMessage() {} func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[46] + mi := &file_proto_v1_gateway_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4274,7 +5154,7 @@ func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteResponse.ProtoReflect.Descriptor instead. func (*HistoryDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{46} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} } type HistoryTruncateRequest struct { @@ -4289,7 +5169,7 @@ type HistoryTruncateRequest struct { func (x *HistoryTruncateRequest) Reset() { *x = HistoryTruncateRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[47] + mi := &file_proto_v1_gateway_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4301,7 +5181,7 @@ func (x *HistoryTruncateRequest) String() string { func (*HistoryTruncateRequest) ProtoMessage() {} func (x *HistoryTruncateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[47] + mi := &file_proto_v1_gateway_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4314,7 +5194,7 @@ func (x *HistoryTruncateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryTruncateRequest.ProtoReflect.Descriptor instead. func (*HistoryTruncateRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{47} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} } func (x *HistoryTruncateRequest) GetConversationId() string { @@ -4356,7 +5236,7 @@ type HistoryTruncateResponse struct { func (x *HistoryTruncateResponse) Reset() { *x = HistoryTruncateResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[48] + mi := &file_proto_v1_gateway_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4368,7 +5248,7 @@ func (x *HistoryTruncateResponse) String() string { func (*HistoryTruncateResponse) ProtoMessage() {} func (x *HistoryTruncateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[48] + mi := &file_proto_v1_gateway_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4381,7 +5261,7 @@ func (x *HistoryTruncateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryTruncateResponse.ProtoReflect.Descriptor instead. func (*HistoryTruncateResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{48} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} } func (x *HistoryTruncateResponse) GetConversationId() string { @@ -4416,7 +5296,7 @@ type HistorySyncEvent struct { func (x *HistorySyncEvent) Reset() { *x = HistorySyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[49] + mi := &file_proto_v1_gateway_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4428,7 +5308,7 @@ func (x *HistorySyncEvent) String() string { func (*HistorySyncEvent) ProtoMessage() {} func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[49] + mi := &file_proto_v1_gateway_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4441,7 +5321,7 @@ func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use HistorySyncEvent.ProtoReflect.Descriptor instead. func (*HistorySyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{49} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} } func (x *HistorySyncEvent) GetKind() string { @@ -4473,7 +5353,7 @@ type ProviderListRequest struct { func (x *ProviderListRequest) Reset() { *x = ProviderListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[50] + mi := &file_proto_v1_gateway_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4485,7 +5365,7 @@ func (x *ProviderListRequest) String() string { func (*ProviderListRequest) ProtoMessage() {} func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[50] + mi := &file_proto_v1_gateway_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4498,7 +5378,7 @@ func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListRequest.ProtoReflect.Descriptor instead. func (*ProviderListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{50} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} } type ProviderListResponse struct { @@ -4510,7 +5390,7 @@ type ProviderListResponse struct { func (x *ProviderListResponse) Reset() { *x = ProviderListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[51] + mi := &file_proto_v1_gateway_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4522,7 +5402,7 @@ func (x *ProviderListResponse) String() string { func (*ProviderListResponse) ProtoMessage() {} func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[51] + mi := &file_proto_v1_gateway_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4535,7 +5415,7 @@ func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListResponse.ProtoReflect.Descriptor instead. func (*ProviderListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{51} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} } func (x *ProviderListResponse) GetProvidersJson() string { @@ -4553,7 +5433,7 @@ type SettingsGetRequest struct { func (x *SettingsGetRequest) Reset() { *x = SettingsGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[52] + mi := &file_proto_v1_gateway_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4565,7 +5445,7 @@ func (x *SettingsGetRequest) String() string { func (*SettingsGetRequest) ProtoMessage() {} func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[52] + mi := &file_proto_v1_gateway_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4578,7 +5458,7 @@ func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetRequest.ProtoReflect.Descriptor instead. func (*SettingsGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{52} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} } type SettingsGetResponse struct { @@ -4590,7 +5470,7 @@ type SettingsGetResponse struct { func (x *SettingsGetResponse) Reset() { *x = SettingsGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[53] + mi := &file_proto_v1_gateway_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4602,7 +5482,7 @@ func (x *SettingsGetResponse) String() string { func (*SettingsGetResponse) ProtoMessage() {} func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[53] + mi := &file_proto_v1_gateway_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4615,7 +5495,7 @@ func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetResponse.ProtoReflect.Descriptor instead. func (*SettingsGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} } func (x *SettingsGetResponse) GetSettingsJson() string { @@ -4634,7 +5514,7 @@ type SettingsUpdateRequest struct { func (x *SettingsUpdateRequest) Reset() { *x = SettingsUpdateRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[54] + mi := &file_proto_v1_gateway_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4646,7 +5526,7 @@ func (x *SettingsUpdateRequest) String() string { func (*SettingsUpdateRequest) ProtoMessage() {} func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[54] + mi := &file_proto_v1_gateway_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4659,7 +5539,7 @@ func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateRequest.ProtoReflect.Descriptor instead. func (*SettingsUpdateRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} } func (x *SettingsUpdateRequest) GetSettingsJson() string { @@ -4679,7 +5559,7 @@ type SettingsUpdateResponse struct { func (x *SettingsUpdateResponse) Reset() { *x = SettingsUpdateResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[55] + mi := &file_proto_v1_gateway_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4691,7 +5571,7 @@ func (x *SettingsUpdateResponse) String() string { func (*SettingsUpdateResponse) ProtoMessage() {} func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[55] + mi := &file_proto_v1_gateway_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4704,7 +5584,7 @@ func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateResponse.ProtoReflect.Descriptor instead. func (*SettingsUpdateResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} } func (x *SettingsUpdateResponse) GetAccepted() bool { @@ -4730,7 +5610,7 @@ type SettingsSyncEvent struct { func (x *SettingsSyncEvent) Reset() { *x = SettingsSyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4742,7 +5622,7 @@ func (x *SettingsSyncEvent) String() string { func (*SettingsSyncEvent) ProtoMessage() {} func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4755,7 +5635,7 @@ func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsSyncEvent.ProtoReflect.Descriptor instead. func (*SettingsSyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} } func (x *SettingsSyncEvent) GetSettingsJson() string { @@ -4773,7 +5653,7 @@ type SkillFilesListRequest struct { func (x *SkillFilesListRequest) Reset() { *x = SkillFilesListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4785,7 +5665,7 @@ func (x *SkillFilesListRequest) String() string { func (*SkillFilesListRequest) ProtoMessage() {} func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4798,7 +5678,7 @@ func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListRequest.ProtoReflect.Descriptor instead. func (*SkillFilesListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} } type SkillFilesListResponse struct { @@ -4812,7 +5692,7 @@ type SkillFilesListResponse struct { func (x *SkillFilesListResponse) Reset() { *x = SkillFilesListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4824,7 +5704,7 @@ func (x *SkillFilesListResponse) String() string { func (*SkillFilesListResponse) ProtoMessage() {} func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4837,7 +5717,7 @@ func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListResponse.ProtoReflect.Descriptor instead. func (*SkillFilesListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} } func (x *SkillFilesListResponse) GetRootDir() string { @@ -4870,7 +5750,7 @@ type SkillMetadataReadRequest struct { func (x *SkillMetadataReadRequest) Reset() { *x = SkillMetadataReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4882,7 +5762,7 @@ func (x *SkillMetadataReadRequest) String() string { func (*SkillMetadataReadRequest) ProtoMessage() {} func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4895,7 +5775,7 @@ func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadRequest.ProtoReflect.Descriptor instead. func (*SkillMetadataReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} } func (x *SkillMetadataReadRequest) GetPath() string { @@ -4915,7 +5795,7 @@ type SkillMetadataReadResponse struct { func (x *SkillMetadataReadResponse) Reset() { *x = SkillMetadataReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4927,7 +5807,7 @@ func (x *SkillMetadataReadResponse) String() string { func (*SkillMetadataReadResponse) ProtoMessage() {} func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4940,7 +5820,7 @@ func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadResponse.ProtoReflect.Descriptor instead. func (*SkillMetadataReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} } func (x *SkillMetadataReadResponse) GetName() string { @@ -4968,7 +5848,7 @@ type SkillTextReadRequest struct { func (x *SkillTextReadRequest) Reset() { *x = SkillTextReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4980,7 +5860,7 @@ func (x *SkillTextReadRequest) String() string { func (*SkillTextReadRequest) ProtoMessage() {} func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4993,7 +5873,7 @@ func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadRequest.ProtoReflect.Descriptor instead. func (*SkillTextReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} } func (x *SkillTextReadRequest) GetPath() string { @@ -5027,7 +5907,7 @@ type SkillTextReadResponse struct { func (x *SkillTextReadResponse) Reset() { *x = SkillTextReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5039,7 +5919,7 @@ func (x *SkillTextReadResponse) String() string { func (*SkillTextReadResponse) ProtoMessage() {} func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5052,7 +5932,7 @@ func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadResponse.ProtoReflect.Descriptor instead. func (*SkillTextReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} } func (x *SkillTextReadResponse) GetContent() string { @@ -5078,7 +5958,7 @@ type SkillManageRequest struct { func (x *SkillManageRequest) Reset() { *x = SkillManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5090,7 +5970,7 @@ func (x *SkillManageRequest) String() string { func (*SkillManageRequest) ProtoMessage() {} func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5103,7 +5983,7 @@ func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageRequest.ProtoReflect.Descriptor instead. func (*SkillManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} } func (x *SkillManageRequest) GetPayloadJson() string { @@ -5122,7 +6002,7 @@ type SkillManageResponse struct { func (x *SkillManageResponse) Reset() { *x = SkillManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5134,7 +6014,7 @@ func (x *SkillManageResponse) String() string { func (*SkillManageResponse) ProtoMessage() {} func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5147,7 +6027,7 @@ func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageResponse.ProtoReflect.Descriptor instead. func (*SkillManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} } func (x *SkillManageResponse) GetResultJson() string { @@ -5168,7 +6048,7 @@ type FileMentionListRequest struct { func (x *FileMentionListRequest) Reset() { *x = FileMentionListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5180,7 +6060,7 @@ func (x *FileMentionListRequest) String() string { func (*FileMentionListRequest) ProtoMessage() {} func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5193,7 +6073,7 @@ func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListRequest.ProtoReflect.Descriptor instead. func (*FileMentionListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} } func (x *FileMentionListRequest) GetWorkdir() string { @@ -5227,7 +6107,7 @@ type FileMentionEntry struct { func (x *FileMentionEntry) Reset() { *x = FileMentionEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5239,7 +6119,7 @@ func (x *FileMentionEntry) String() string { func (*FileMentionEntry) ProtoMessage() {} func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5252,7 +6132,7 @@ func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionEntry.ProtoReflect.Descriptor instead. func (*FileMentionEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} } func (x *FileMentionEntry) GetPath() string { @@ -5279,7 +6159,7 @@ type FileMentionListResponse struct { func (x *FileMentionListResponse) Reset() { *x = FileMentionListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5291,7 +6171,7 @@ func (x *FileMentionListResponse) String() string { func (*FileMentionListResponse) ProtoMessage() {} func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5304,7 +6184,7 @@ func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListResponse.ProtoReflect.Descriptor instead. func (*FileMentionListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} } func (x *FileMentionListResponse) GetEntries() []*FileMentionEntry { @@ -5333,7 +6213,7 @@ type FsRoot struct { func (x *FsRoot) Reset() { *x = FsRoot{} - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5345,7 +6225,7 @@ func (x *FsRoot) String() string { func (*FsRoot) ProtoMessage() {} func (x *FsRoot) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5358,7 +6238,7 @@ func (x *FsRoot) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRoot.ProtoReflect.Descriptor instead. func (*FsRoot) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} } func (x *FsRoot) GetId() string { @@ -5397,7 +6277,7 @@ type FsRootsRequest struct { func (x *FsRootsRequest) Reset() { *x = FsRootsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5409,7 +6289,7 @@ func (x *FsRootsRequest) String() string { func (*FsRootsRequest) ProtoMessage() {} func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5422,7 +6302,7 @@ func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsRequest.ProtoReflect.Descriptor instead. func (*FsRootsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} } type FsRootsResponse struct { @@ -5434,7 +6314,7 @@ type FsRootsResponse struct { func (x *FsRootsResponse) Reset() { *x = FsRootsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5446,7 +6326,7 @@ func (x *FsRootsResponse) String() string { func (*FsRootsResponse) ProtoMessage() {} func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5459,7 +6339,7 @@ func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsResponse.ProtoReflect.Descriptor instead. func (*FsRootsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} } func (x *FsRootsResponse) GetRoots() []*FsRoot { @@ -5479,7 +6359,7 @@ type FsListDirsRequest struct { func (x *FsListDirsRequest) Reset() { *x = FsListDirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5491,7 +6371,7 @@ func (x *FsListDirsRequest) String() string { func (*FsListDirsRequest) ProtoMessage() {} func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5504,7 +6384,7 @@ func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsRequest.ProtoReflect.Descriptor instead. func (*FsListDirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} } func (x *FsListDirsRequest) GetPath() string { @@ -5531,7 +6411,7 @@ type FsDirEntry struct { func (x *FsDirEntry) Reset() { *x = FsDirEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5543,7 +6423,7 @@ func (x *FsDirEntry) String() string { func (*FsDirEntry) ProtoMessage() {} func (x *FsDirEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5556,7 +6436,7 @@ func (x *FsDirEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDirEntry.ProtoReflect.Descriptor instead. func (*FsDirEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} } func (x *FsDirEntry) GetPath() string { @@ -5584,7 +6464,7 @@ type FsListDirsResponse struct { func (x *FsListDirsResponse) Reset() { *x = FsListDirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5596,7 +6476,7 @@ func (x *FsListDirsResponse) String() string { func (*FsListDirsResponse) ProtoMessage() {} func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5609,7 +6489,7 @@ func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsResponse.ProtoReflect.Descriptor instead. func (*FsListDirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} } func (x *FsListDirsResponse) GetPath() string { @@ -5643,7 +6523,7 @@ type FsCreateProjectFolderRequest struct { func (x *FsCreateProjectFolderRequest) Reset() { *x = FsCreateProjectFolderRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5655,7 +6535,7 @@ func (x *FsCreateProjectFolderRequest) String() string { func (*FsCreateProjectFolderRequest) ProtoMessage() {} func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5668,7 +6548,7 @@ func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderRequest.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} } func (x *FsCreateProjectFolderRequest) GetParent() string { @@ -5694,7 +6574,7 @@ type FsCreateProjectFolderResponse struct { func (x *FsCreateProjectFolderResponse) Reset() { *x = FsCreateProjectFolderResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5706,7 +6586,7 @@ func (x *FsCreateProjectFolderResponse) String() string { func (*FsCreateProjectFolderResponse) ProtoMessage() {} func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5719,7 +6599,7 @@ func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderResponse.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} } func (x *FsCreateProjectFolderResponse) GetPath() string { @@ -5742,7 +6622,7 @@ type FsListRequest struct { func (x *FsListRequest) Reset() { *x = FsListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5754,7 +6634,7 @@ func (x *FsListRequest) String() string { func (*FsListRequest) ProtoMessage() {} func (x *FsListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5767,7 +6647,7 @@ func (x *FsListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListRequest.ProtoReflect.Descriptor instead. func (*FsListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} } func (x *FsListRequest) GetWorkdir() string { @@ -5815,7 +6695,7 @@ type FsListEntry struct { func (x *FsListEntry) Reset() { *x = FsListEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5827,7 +6707,7 @@ func (x *FsListEntry) String() string { func (*FsListEntry) ProtoMessage() {} func (x *FsListEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5840,7 +6720,7 @@ func (x *FsListEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListEntry.ProtoReflect.Descriptor instead. func (*FsListEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} } func (x *FsListEntry) GetPath() string { @@ -5873,7 +6753,7 @@ type FsListResponse struct { func (x *FsListResponse) Reset() { *x = FsListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5885,7 +6765,7 @@ func (x *FsListResponse) String() string { func (*FsListResponse) ProtoMessage() {} func (x *FsListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5898,7 +6778,7 @@ func (x *FsListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListResponse.ProtoReflect.Descriptor instead. func (*FsListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} } func (x *FsListResponse) GetPath() string { @@ -5967,7 +6847,7 @@ type FsReadEditableTextRequest struct { func (x *FsReadEditableTextRequest) Reset() { *x = FsReadEditableTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5979,7 +6859,7 @@ func (x *FsReadEditableTextRequest) String() string { func (*FsReadEditableTextRequest) ProtoMessage() {} func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5992,7 +6872,7 @@ func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextRequest.ProtoReflect.Descriptor instead. func (*FsReadEditableTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} } func (x *FsReadEditableTextRequest) GetWorkdir() string { @@ -6023,7 +6903,7 @@ type FsReadEditableTextResponse struct { func (x *FsReadEditableTextResponse) Reset() { *x = FsReadEditableTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6035,7 +6915,7 @@ func (x *FsReadEditableTextResponse) String() string { func (*FsReadEditableTextResponse) ProtoMessage() {} func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6048,7 +6928,7 @@ func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextResponse.ProtoReflect.Descriptor instead. func (*FsReadEditableTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} } func (x *FsReadEditableTextResponse) GetPath() string { @@ -6103,7 +6983,7 @@ type FsReadWorkspaceImageRequest struct { func (x *FsReadWorkspaceImageRequest) Reset() { *x = FsReadWorkspaceImageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6115,7 +6995,7 @@ func (x *FsReadWorkspaceImageRequest) String() string { func (*FsReadWorkspaceImageRequest) ProtoMessage() {} func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6128,7 +7008,7 @@ func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageRequest.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} } func (x *FsReadWorkspaceImageRequest) GetWorkdir() string { @@ -6159,7 +7039,7 @@ type FsReadWorkspaceImageResponse struct { func (x *FsReadWorkspaceImageResponse) Reset() { *x = FsReadWorkspaceImageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6171,7 +7051,7 @@ func (x *FsReadWorkspaceImageResponse) String() string { func (*FsReadWorkspaceImageResponse) ProtoMessage() {} func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6184,7 +7064,7 @@ func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageResponse.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} } func (x *FsReadWorkspaceImageResponse) GetPath() string { @@ -6245,7 +7125,7 @@ type FsWriteTextRequest struct { func (x *FsWriteTextRequest) Reset() { *x = FsWriteTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6257,7 +7137,7 @@ func (x *FsWriteTextRequest) String() string { func (*FsWriteTextRequest) ProtoMessage() {} func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6270,7 +7150,7 @@ func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextRequest.ProtoReflect.Descriptor instead. func (*FsWriteTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} } func (x *FsWriteTextRequest) GetWorkdir() string { @@ -6344,7 +7224,7 @@ type FsWriteTextResponse struct { func (x *FsWriteTextResponse) Reset() { *x = FsWriteTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6356,7 +7236,7 @@ func (x *FsWriteTextResponse) String() string { func (*FsWriteTextResponse) ProtoMessage() {} func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6369,7 +7249,7 @@ func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextResponse.ProtoReflect.Descriptor instead. func (*FsWriteTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} } func (x *FsWriteTextResponse) GetPath() string { @@ -6431,7 +7311,7 @@ type FsCreateDirRequest struct { func (x *FsCreateDirRequest) Reset() { *x = FsCreateDirRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6443,7 +7323,7 @@ func (x *FsCreateDirRequest) String() string { func (*FsCreateDirRequest) ProtoMessage() {} func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6456,7 +7336,7 @@ func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirRequest.ProtoReflect.Descriptor instead. func (*FsCreateDirRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} } func (x *FsCreateDirRequest) GetWorkdir() string { @@ -6483,7 +7363,7 @@ type FsCreateDirResponse struct { func (x *FsCreateDirResponse) Reset() { *x = FsCreateDirResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6495,7 +7375,7 @@ func (x *FsCreateDirResponse) String() string { func (*FsCreateDirResponse) ProtoMessage() {} func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6508,7 +7388,7 @@ func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirResponse.ProtoReflect.Descriptor instead. func (*FsCreateDirResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} } func (x *FsCreateDirResponse) GetPath() string { @@ -6536,7 +7416,7 @@ type FsRenameRequest struct { func (x *FsRenameRequest) Reset() { *x = FsRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6548,7 +7428,7 @@ func (x *FsRenameRequest) String() string { func (*FsRenameRequest) ProtoMessage() {} func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6561,7 +7441,7 @@ func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameRequest.ProtoReflect.Descriptor instead. func (*FsRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{94} } func (x *FsRenameRequest) GetWorkdir() string { @@ -6596,7 +7476,7 @@ type FsRenameResponse struct { func (x *FsRenameResponse) Reset() { *x = FsRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6608,7 +7488,7 @@ func (x *FsRenameResponse) String() string { func (*FsRenameResponse) ProtoMessage() {} func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6621,7 +7501,7 @@ func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameResponse.ProtoReflect.Descriptor instead. func (*FsRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{95} } func (x *FsRenameResponse) GetFromPath() string { @@ -6655,7 +7535,7 @@ type FsDeleteRequest struct { func (x *FsDeleteRequest) Reset() { *x = FsDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6667,7 +7547,7 @@ func (x *FsDeleteRequest) String() string { func (*FsDeleteRequest) ProtoMessage() {} func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6680,7 +7560,7 @@ func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteRequest.ProtoReflect.Descriptor instead. func (*FsDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{96} } func (x *FsDeleteRequest) GetWorkdir() string { @@ -6707,7 +7587,7 @@ type FsDeleteResponse struct { func (x *FsDeleteResponse) Reset() { *x = FsDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6719,7 +7599,7 @@ func (x *FsDeleteResponse) String() string { func (*FsDeleteResponse) ProtoMessage() {} func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6732,7 +7612,7 @@ func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteResponse.ProtoReflect.Descriptor instead. func (*FsDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{97} } func (x *FsDeleteResponse) GetPath() string { @@ -6758,7 +7638,7 @@ type PingRequest struct { func (x *PingRequest) Reset() { *x = PingRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6770,7 +7650,7 @@ func (x *PingRequest) String() string { func (*PingRequest) ProtoMessage() {} func (x *PingRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6783,7 +7663,7 @@ func (x *PingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. func (*PingRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{98} } func (x *PingRequest) GetTimestamp() int64 { @@ -6802,7 +7682,7 @@ type PongResponse struct { func (x *PongResponse) Reset() { *x = PongResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6814,7 +7694,7 @@ func (x *PongResponse) String() string { func (*PongResponse) ProtoMessage() {} func (x *PongResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6827,7 +7707,7 @@ func (x *PongResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PongResponse.ProtoReflect.Descriptor instead. func (*PongResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{99} } func (x *PongResponse) GetTimestamp() int64 { @@ -6847,7 +7727,7 @@ type ErrorResponse struct { func (x *ErrorResponse) Reset() { *x = ErrorResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6859,7 +7739,7 @@ func (x *ErrorResponse) String() string { func (*ErrorResponse) ProtoMessage() {} func (x *ErrorResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6872,7 +7752,7 @@ func (x *ErrorResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ErrorResponse.ProtoReflect.Descriptor instead. func (*ErrorResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{100} } func (x *ErrorResponse) GetCode() int32 { @@ -6902,7 +7782,7 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1d\n" + "\n" + - "session_id\x18\x03 \x01(\tR\tsessionId\"\x89\x19\n" + + "session_id\x18\x03 \x01(\tR\tsessionId\"\x85\x1b\n" + "\x0fGatewayEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -6950,8 +7830,11 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\vgit_request\x18= \x01(\v2 .liveagent.gateway.v1.GitRequestH\x00R\n" + "gitRequest\x12d\n" + "\x15fs_read_editable_text\x18> \x01(\v2/.liveagent.gateway.v1.FsReadEditableTextRequestH\x00R\x12fsReadEditableText\x12j\n" + - "\x17fs_read_workspace_image\x18? \x01(\v21.liveagent.gateway.v1.FsReadWorkspaceImageRequestH\x00R\x14fsReadWorkspaceImageB\t\n" + - "\apayload\"\xa4\x1d\n" + + "\x17fs_read_workspace_image\x18? \x01(\v21.liveagent.gateway.v1.FsReadWorkspaceImageRequestH\x00R\x14fsReadWorkspaceImage\x12S\n" + + "\x0etunnel_control\x18C \x01(\v2*.liveagent.gateway.v1.TunnelControlRequestH\x00R\rtunnelControl\x12]\n" + + "\x13tunnel_control_resp\x18D \x01(\v2+.liveagent.gateway.v1.TunnelControlResponseH\x00R\x11tunnelControlResp\x12F\n" + + "\ftunnel_frame\x18E \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrameB\t\n" + + "\apayload\"\xc0 \n" + "\rAgentEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -6997,7 +7880,12 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\x0efs_delete_resp\x18? \x01(\v2&.liveagent.gateway.v1.FsDeleteResponseH\x00R\ffsDeleteResp\x12F\n" + "\fgit_response\x18@ \x01(\v2!.liveagent.gateway.v1.GitResponseH\x00R\vgitResponse\x12n\n" + "\x1afs_read_editable_text_resp\x18A \x01(\v20.liveagent.gateway.v1.FsReadEditableTextResponseH\x00R\x16fsReadEditableTextResp\x12t\n" + - "\x1cfs_read_workspace_image_resp\x18B \x01(\v22.liveagent.gateway.v1.FsReadWorkspaceImageResponseH\x00R\x18fsReadWorkspaceImageResp\x12;\n" + + "\x1cfs_read_workspace_image_resp\x18B \x01(\v22.liveagent.gateway.v1.FsReadWorkspaceImageResponseH\x00R\x18fsReadWorkspaceImageResp\x12S\n" + + "\x0etunnel_control\x18C \x01(\v2*.liveagent.gateway.v1.TunnelControlRequestH\x00R\rtunnelControl\x12]\n" + + "\x13tunnel_control_resp\x18D \x01(\v2+.liveagent.gateway.v1.TunnelControlResponseH\x00R\x11tunnelControlResp\x12F\n" + + "\ftunnel_frame\x18E \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrame\x12K\n" + + "\fchat_control\x18F \x01(\v2&.liveagent.gateway.v1.ChatControlEventH\x00R\vchatControl\x12Q\n" + + "\x0eruntime_status\x18G \x01(\v2(.liveagent.gateway.v1.RuntimeStatusEventH\x00R\rruntimeStatus\x12;\n" + "\x05error\x18c \x01(\v2#.liveagent.gateway.v1.ErrorResponseH\x00R\x05errorB\t\n" + "\apayload\"|\n" + "\x11ChatSelectedModel\x12,\n" + @@ -7030,7 +7918,65 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\rabsolute_path\x18\x02 \x01(\tR\fabsolutePath\"O\n" + "\x1cUploadedImagePreviewResponse\x12\x1b\n" + "\tmime_type\x18\x01 \x01(\tR\bmimeType\x12\x12\n" + - "\x04data\x18\x02 \x01(\tR\x04data\"L\n" + + "\x04data\x18\x02 \x01(\tR\x04data\"\xc3\x02\n" + + "\x14TunnelControlRequest\x12\x16\n" + + "\x06action\x18\x01 \x01(\tR\x06action\x12\x1b\n" + + "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x12\n" + + "\x04slug\x18\x03 \x01(\tR\x04slug\x12\x1d\n" + + "\n" + + "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x12\n" + + "\x04name\x18\x05 \x01(\tR\x04name\x12\x1f\n" + + "\vttl_seconds\x18\x06 \x01(\rR\n" + + "ttlSeconds\x12\x1d\n" + + "\n" + + "expires_at\x18\a \x01(\x03R\texpiresAt\x12\x1d\n" + + "\n" + + "public_url\x18\b \x01(\tR\tpublicUrl\x12&\n" + + "\x0fpublic_base_url\x18\t \x01(\tR\rpublicBaseUrl\x12(\n" + + "\x10project_path_key\x18\n" + + " \x01(\tR\x0eprojectPathKey\"\xef\x01\n" + + "\x15TunnelControlResponse\x12\x16\n" + + "\x06action\x18\x01 \x01(\tR\x06action\x12=\n" + + "\atunnels\x18\x02 \x03(\v2#.liveagent.gateway.v1.TunnelSummaryR\atunnels\x12;\n" + + "\x06tunnel\x18\x03 \x01(\v2#.liveagent.gateway.v1.TunnelSummaryR\x06tunnel\x12\x1d\n" + + "\n" + + "error_code\x18\x04 \x01(\tR\terrorCode\x12#\n" + + "\rerror_message\x18\x05 \x01(\tR\ferrorMessage\"\xb4\x02\n" + + "\rTunnelSummary\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04slug\x18\x02 \x01(\tR\x04slug\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x1d\n" + + "\n" + + "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x1d\n" + + "\n" + + "public_url\x18\x05 \x01(\tR\tpublicUrl\x12\x1d\n" + + "\n" + + "created_at\x18\x06 \x01(\x03R\tcreatedAt\x12\x1d\n" + + "\n" + + "expires_at\x18\a \x01(\x03R\texpiresAt\x12-\n" + + "\x12active_connections\x18\b \x01(\rR\x11activeConnections\x12\x16\n" + + "\x06status\x18\t \x01(\tR\x06status\x12(\n" + + "\x10project_path_key\x18\n" + + " \x01(\tR\x0eprojectPathKey\"8\n" + + "\fTunnelHeader\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\"\x92\x03\n" + + "\vTunnelFrame\x12\x1b\n" + + "\tstream_id\x18\x01 \x01(\tR\bstreamId\x12\x1b\n" + + "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x12\n" + + "\x04slug\x18\x03 \x01(\tR\x04slug\x129\n" + + "\x04kind\x18\x04 \x01(\x0e2%.liveagent.gateway.v1.TunnelFrameKindR\x04kind\x12\x16\n" + + "\x06method\x18\x05 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x06 \x01(\tR\x04path\x12<\n" + + "\aheaders\x18\a \x03(\v2\".liveagent.gateway.v1.TunnelHeaderR\aheaders\x12\x1f\n" + + "\vstatus_code\x18\b \x01(\rR\n" + + "statusCode\x12\x12\n" + + "\x04body\x18\t \x01(\fR\x04body\x12\x1d\n" + + "\n" + + "end_stream\x18\n" + + " \x01(\bR\tendStream\x12\x14\n" + + "\x05error\x18\v \x01(\tR\x05error\x12&\n" + + "\x0fws_message_type\x18\f \x01(\tR\rwsMessageType\"L\n" + "\x13MemoryManageRequest\x12\x18\n" + "\acommand\x18\x01 \x01(\tR\acommand\x12\x1b\n" + "\targs_json\x18\x02 \x01(\tR\bargsJson\"7\n" + @@ -7124,7 +8070,25 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\x04DONE\x10\x04\x12\t\n" + "\x05ERROR\x10\x05\x12\x0f\n" + "\vTOOL_STATUS\x10\x06\x12\x11\n" + - "\rHOSTED_SEARCH\x10\a\"a\n" + + "\rHOSTED_SEARCH\x10\a\"\x98\x02\n" + + "\x10ChatControlEvent\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x12*\n" + + "\x11client_request_id\x18\x02 \x01(\tR\x0fclientRequestId\x12'\n" + + "\x0fconversation_id\x18\x03 \x01(\tR\x0econversationId\x12\x1b\n" + + "\trun_epoch\x18\x04 \x01(\x03R\brunEpoch\x12\x12\n" + + "\x04type\x18\x05 \x01(\tR\x04type\x12\x14\n" + + "\x05state\x18\x06 \x01(\tR\x05state\x12\x1d\n" + + "\n" + + "error_code\x18\a \x01(\tR\terrorCode\x12\x18\n" + + "\amessage\x18\b \x01(\tR\amessage\x12\x10\n" + + "\x03seq\x18\t \x01(\x03R\x03seq\"\xa9\x01\n" + + "\x12RuntimeStatusEvent\x12\x1b\n" + + "\tworker_id\x18\x01 \x01(\tR\bworkerId\x12\x14\n" + + "\x05state\x18\x02 \x01(\tR\x05state\x12\x18\n" + + "\avisible\x18\x03 \x01(\bR\avisible\x12(\n" + + "\x10active_run_count\x18\x04 \x01(\rR\x0eactiveRunCount\x12\x1c\n" + + "\ttimestamp\x18\x05 \x01(\x03R\ttimestamp\"a\n" + "\x11CronManageRequest\x12\x16\n" + "\x06action\x18\x01 \x01(\tR\x06action\x12\x17\n" + "\atask_id\x18\x02 \x01(\tR\x06taskId\x12\x1b\n" + @@ -7390,7 +8354,21 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\ttimestamp\x18\x01 \x01(\x03R\ttimestamp\"=\n" + "\rErrorResponse\x12\x12\n" + "\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage2\xc5\x01\n" + + "\amessage\x18\x02 \x01(\tR\amessage*\xc7\x03\n" + + "\x0fTunnelFrameKind\x12!\n" + + "\x1dTUNNEL_FRAME_KIND_UNSPECIFIED\x10\x00\x12(\n" + + "$TUNNEL_FRAME_KIND_HTTP_REQUEST_START\x10\x01\x12'\n" + + "#TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY\x10\x02\x12&\n" + + "\"TUNNEL_FRAME_KIND_HTTP_REQUEST_END\x10\x03\x12)\n" + + "%TUNNEL_FRAME_KIND_HTTP_RESPONSE_START\x10\x04\x12(\n" + + "$TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY\x10\x05\x12'\n" + + "#TUNNEL_FRAME_KIND_HTTP_RESPONSE_END\x10\x06\x12\x1d\n" + + "\x19TUNNEL_FRAME_KIND_WS_OPEN\x10\a\x12\x1e\n" + + "\x1aTUNNEL_FRAME_KIND_WS_FRAME\x10\b\x12\x1e\n" + + "\x1aTUNNEL_FRAME_KIND_WS_CLOSE\x10\t\x12\x1b\n" + + "\x17TUNNEL_FRAME_KIND_ERROR\x10\n" + + "\x12\x1c\n" + + "\x18TUNNEL_FRAME_KIND_CANCEL\x10\v2\xc5\x01\n" + "\fAgentGateway\x12^\n" + "\fAgentConnect\x12#.liveagent.gateway.v1.AgentEnvelope\x1a%.liveagent.gateway.v1.GatewayEnvelope(\x010\x01\x12U\n" + "\fAuthenticate\x12!.liveagent.gateway.v1.AuthRequest\x1a\".liveagent.gateway.v1.AuthResponseB@Z>github.com/liveagent/agent-gateway/internal/proto/v1;gatewayv1b\x06proto3" @@ -7407,216 +8385,236 @@ func file_proto_v1_gateway_proto_rawDescGZIP() []byte { return file_proto_v1_gateway_proto_rawDescData } -var file_proto_v1_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 94) +var file_proto_v1_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 101) var file_proto_v1_gateway_proto_goTypes = []any{ - (ChatEvent_ChatEventType)(0), // 0: liveagent.gateway.v1.ChatEvent.ChatEventType - (*AuthRequest)(nil), // 1: liveagent.gateway.v1.AuthRequest - (*AuthResponse)(nil), // 2: liveagent.gateway.v1.AuthResponse - (*GatewayEnvelope)(nil), // 3: liveagent.gateway.v1.GatewayEnvelope - (*AgentEnvelope)(nil), // 4: liveagent.gateway.v1.AgentEnvelope - (*ChatSelectedModel)(nil), // 5: liveagent.gateway.v1.ChatSelectedModel - (*ChatRuntimeControls)(nil), // 6: liveagent.gateway.v1.ChatRuntimeControls - (*ChatUploadedFile)(nil), // 7: liveagent.gateway.v1.ChatUploadedFile - (*UploadReadableFile)(nil), // 8: liveagent.gateway.v1.UploadReadableFile - (*UploadReadableFilesRequest)(nil), // 9: liveagent.gateway.v1.UploadReadableFilesRequest - (*UploadReadableFilesResponse)(nil), // 10: liveagent.gateway.v1.UploadReadableFilesResponse - (*UploadedImagePreviewRequest)(nil), // 11: liveagent.gateway.v1.UploadedImagePreviewRequest - (*UploadedImagePreviewResponse)(nil), // 12: liveagent.gateway.v1.UploadedImagePreviewResponse - (*MemoryManageRequest)(nil), // 13: liveagent.gateway.v1.MemoryManageRequest - (*MemoryManageResponse)(nil), // 14: liveagent.gateway.v1.MemoryManageResponse - (*TerminalRequest)(nil), // 15: liveagent.gateway.v1.TerminalRequest - (*TerminalSession)(nil), // 16: liveagent.gateway.v1.TerminalSession - (*TerminalShellOption)(nil), // 17: liveagent.gateway.v1.TerminalShellOption - (*TerminalResponse)(nil), // 18: liveagent.gateway.v1.TerminalResponse - (*TerminalEvent)(nil), // 19: liveagent.gateway.v1.TerminalEvent - (*GitRequest)(nil), // 20: liveagent.gateway.v1.GitRequest - (*GitResponse)(nil), // 21: liveagent.gateway.v1.GitResponse - (*ChatRequest)(nil), // 22: liveagent.gateway.v1.ChatRequest - (*CancelChatRequest)(nil), // 23: liveagent.gateway.v1.CancelChatRequest - (*ChatEvent)(nil), // 24: liveagent.gateway.v1.ChatEvent - (*CronManageRequest)(nil), // 25: liveagent.gateway.v1.CronManageRequest - (*CronManageResponse)(nil), // 26: liveagent.gateway.v1.CronManageResponse - (*HistoryListRequest)(nil), // 27: liveagent.gateway.v1.HistoryListRequest - (*HistoryListResponse)(nil), // 28: liveagent.gateway.v1.HistoryListResponse - (*ConversationSummary)(nil), // 29: liveagent.gateway.v1.ConversationSummary - (*HistoryGetRequest)(nil), // 30: liveagent.gateway.v1.HistoryGetRequest - (*HistoryGetResponse)(nil), // 31: liveagent.gateway.v1.HistoryGetResponse - (*HistoryRenameRequest)(nil), // 32: liveagent.gateway.v1.HistoryRenameRequest - (*HistoryRenameResponse)(nil), // 33: liveagent.gateway.v1.HistoryRenameResponse - (*HistoryPinRequest)(nil), // 34: liveagent.gateway.v1.HistoryPinRequest - (*HistoryPinResponse)(nil), // 35: liveagent.gateway.v1.HistoryPinResponse - (*HistoryShareStatus)(nil), // 36: liveagent.gateway.v1.HistoryShareStatus - (*HistoryShareGetRequest)(nil), // 37: liveagent.gateway.v1.HistoryShareGetRequest - (*HistoryShareGetResponse)(nil), // 38: liveagent.gateway.v1.HistoryShareGetResponse - (*HistoryShareSetRequest)(nil), // 39: liveagent.gateway.v1.HistoryShareSetRequest - (*HistoryShareSetResponse)(nil), // 40: liveagent.gateway.v1.HistoryShareSetResponse - (*HistoryShareResolveRequest)(nil), // 41: liveagent.gateway.v1.HistoryShareResolveRequest - (*HistoryShareResolveResponse)(nil), // 42: liveagent.gateway.v1.HistoryShareResolveResponse - (*HistoryWorkdirsRequest)(nil), // 43: liveagent.gateway.v1.HistoryWorkdirsRequest - (*HistoryWorkdirSummary)(nil), // 44: liveagent.gateway.v1.HistoryWorkdirSummary - (*HistoryWorkdirsResponse)(nil), // 45: liveagent.gateway.v1.HistoryWorkdirsResponse - (*HistoryDeleteRequest)(nil), // 46: liveagent.gateway.v1.HistoryDeleteRequest - (*HistoryDeleteResponse)(nil), // 47: liveagent.gateway.v1.HistoryDeleteResponse - (*HistoryTruncateRequest)(nil), // 48: liveagent.gateway.v1.HistoryTruncateRequest - (*HistoryTruncateResponse)(nil), // 49: liveagent.gateway.v1.HistoryTruncateResponse - (*HistorySyncEvent)(nil), // 50: liveagent.gateway.v1.HistorySyncEvent - (*ProviderListRequest)(nil), // 51: liveagent.gateway.v1.ProviderListRequest - (*ProviderListResponse)(nil), // 52: liveagent.gateway.v1.ProviderListResponse - (*SettingsGetRequest)(nil), // 53: liveagent.gateway.v1.SettingsGetRequest - (*SettingsGetResponse)(nil), // 54: liveagent.gateway.v1.SettingsGetResponse - (*SettingsUpdateRequest)(nil), // 55: liveagent.gateway.v1.SettingsUpdateRequest - (*SettingsUpdateResponse)(nil), // 56: liveagent.gateway.v1.SettingsUpdateResponse - (*SettingsSyncEvent)(nil), // 57: liveagent.gateway.v1.SettingsSyncEvent - (*SkillFilesListRequest)(nil), // 58: liveagent.gateway.v1.SkillFilesListRequest - (*SkillFilesListResponse)(nil), // 59: liveagent.gateway.v1.SkillFilesListResponse - (*SkillMetadataReadRequest)(nil), // 60: liveagent.gateway.v1.SkillMetadataReadRequest - (*SkillMetadataReadResponse)(nil), // 61: liveagent.gateway.v1.SkillMetadataReadResponse - (*SkillTextReadRequest)(nil), // 62: liveagent.gateway.v1.SkillTextReadRequest - (*SkillTextReadResponse)(nil), // 63: liveagent.gateway.v1.SkillTextReadResponse - (*SkillManageRequest)(nil), // 64: liveagent.gateway.v1.SkillManageRequest - (*SkillManageResponse)(nil), // 65: liveagent.gateway.v1.SkillManageResponse - (*FileMentionListRequest)(nil), // 66: liveagent.gateway.v1.FileMentionListRequest - (*FileMentionEntry)(nil), // 67: liveagent.gateway.v1.FileMentionEntry - (*FileMentionListResponse)(nil), // 68: liveagent.gateway.v1.FileMentionListResponse - (*FsRoot)(nil), // 69: liveagent.gateway.v1.FsRoot - (*FsRootsRequest)(nil), // 70: liveagent.gateway.v1.FsRootsRequest - (*FsRootsResponse)(nil), // 71: liveagent.gateway.v1.FsRootsResponse - (*FsListDirsRequest)(nil), // 72: liveagent.gateway.v1.FsListDirsRequest - (*FsDirEntry)(nil), // 73: liveagent.gateway.v1.FsDirEntry - (*FsListDirsResponse)(nil), // 74: liveagent.gateway.v1.FsListDirsResponse - (*FsCreateProjectFolderRequest)(nil), // 75: liveagent.gateway.v1.FsCreateProjectFolderRequest - (*FsCreateProjectFolderResponse)(nil), // 76: liveagent.gateway.v1.FsCreateProjectFolderResponse - (*FsListRequest)(nil), // 77: liveagent.gateway.v1.FsListRequest - (*FsListEntry)(nil), // 78: liveagent.gateway.v1.FsListEntry - (*FsListResponse)(nil), // 79: liveagent.gateway.v1.FsListResponse - (*FsReadEditableTextRequest)(nil), // 80: liveagent.gateway.v1.FsReadEditableTextRequest - (*FsReadEditableTextResponse)(nil), // 81: liveagent.gateway.v1.FsReadEditableTextResponse - (*FsReadWorkspaceImageRequest)(nil), // 82: liveagent.gateway.v1.FsReadWorkspaceImageRequest - (*FsReadWorkspaceImageResponse)(nil), // 83: liveagent.gateway.v1.FsReadWorkspaceImageResponse - (*FsWriteTextRequest)(nil), // 84: liveagent.gateway.v1.FsWriteTextRequest - (*FsWriteTextResponse)(nil), // 85: liveagent.gateway.v1.FsWriteTextResponse - (*FsCreateDirRequest)(nil), // 86: liveagent.gateway.v1.FsCreateDirRequest - (*FsCreateDirResponse)(nil), // 87: liveagent.gateway.v1.FsCreateDirResponse - (*FsRenameRequest)(nil), // 88: liveagent.gateway.v1.FsRenameRequest - (*FsRenameResponse)(nil), // 89: liveagent.gateway.v1.FsRenameResponse - (*FsDeleteRequest)(nil), // 90: liveagent.gateway.v1.FsDeleteRequest - (*FsDeleteResponse)(nil), // 91: liveagent.gateway.v1.FsDeleteResponse - (*PingRequest)(nil), // 92: liveagent.gateway.v1.PingRequest - (*PongResponse)(nil), // 93: liveagent.gateway.v1.PongResponse - (*ErrorResponse)(nil), // 94: liveagent.gateway.v1.ErrorResponse + (TunnelFrameKind)(0), // 0: liveagent.gateway.v1.TunnelFrameKind + (ChatEvent_ChatEventType)(0), // 1: liveagent.gateway.v1.ChatEvent.ChatEventType + (*AuthRequest)(nil), // 2: liveagent.gateway.v1.AuthRequest + (*AuthResponse)(nil), // 3: liveagent.gateway.v1.AuthResponse + (*GatewayEnvelope)(nil), // 4: liveagent.gateway.v1.GatewayEnvelope + (*AgentEnvelope)(nil), // 5: liveagent.gateway.v1.AgentEnvelope + (*ChatSelectedModel)(nil), // 6: liveagent.gateway.v1.ChatSelectedModel + (*ChatRuntimeControls)(nil), // 7: liveagent.gateway.v1.ChatRuntimeControls + (*ChatUploadedFile)(nil), // 8: liveagent.gateway.v1.ChatUploadedFile + (*UploadReadableFile)(nil), // 9: liveagent.gateway.v1.UploadReadableFile + (*UploadReadableFilesRequest)(nil), // 10: liveagent.gateway.v1.UploadReadableFilesRequest + (*UploadReadableFilesResponse)(nil), // 11: liveagent.gateway.v1.UploadReadableFilesResponse + (*UploadedImagePreviewRequest)(nil), // 12: liveagent.gateway.v1.UploadedImagePreviewRequest + (*UploadedImagePreviewResponse)(nil), // 13: liveagent.gateway.v1.UploadedImagePreviewResponse + (*TunnelControlRequest)(nil), // 14: liveagent.gateway.v1.TunnelControlRequest + (*TunnelControlResponse)(nil), // 15: liveagent.gateway.v1.TunnelControlResponse + (*TunnelSummary)(nil), // 16: liveagent.gateway.v1.TunnelSummary + (*TunnelHeader)(nil), // 17: liveagent.gateway.v1.TunnelHeader + (*TunnelFrame)(nil), // 18: liveagent.gateway.v1.TunnelFrame + (*MemoryManageRequest)(nil), // 19: liveagent.gateway.v1.MemoryManageRequest + (*MemoryManageResponse)(nil), // 20: liveagent.gateway.v1.MemoryManageResponse + (*TerminalRequest)(nil), // 21: liveagent.gateway.v1.TerminalRequest + (*TerminalSession)(nil), // 22: liveagent.gateway.v1.TerminalSession + (*TerminalShellOption)(nil), // 23: liveagent.gateway.v1.TerminalShellOption + (*TerminalResponse)(nil), // 24: liveagent.gateway.v1.TerminalResponse + (*TerminalEvent)(nil), // 25: liveagent.gateway.v1.TerminalEvent + (*GitRequest)(nil), // 26: liveagent.gateway.v1.GitRequest + (*GitResponse)(nil), // 27: liveagent.gateway.v1.GitResponse + (*ChatRequest)(nil), // 28: liveagent.gateway.v1.ChatRequest + (*CancelChatRequest)(nil), // 29: liveagent.gateway.v1.CancelChatRequest + (*ChatEvent)(nil), // 30: liveagent.gateway.v1.ChatEvent + (*ChatControlEvent)(nil), // 31: liveagent.gateway.v1.ChatControlEvent + (*RuntimeStatusEvent)(nil), // 32: liveagent.gateway.v1.RuntimeStatusEvent + (*CronManageRequest)(nil), // 33: liveagent.gateway.v1.CronManageRequest + (*CronManageResponse)(nil), // 34: liveagent.gateway.v1.CronManageResponse + (*HistoryListRequest)(nil), // 35: liveagent.gateway.v1.HistoryListRequest + (*HistoryListResponse)(nil), // 36: liveagent.gateway.v1.HistoryListResponse + (*ConversationSummary)(nil), // 37: liveagent.gateway.v1.ConversationSummary + (*HistoryGetRequest)(nil), // 38: liveagent.gateway.v1.HistoryGetRequest + (*HistoryGetResponse)(nil), // 39: liveagent.gateway.v1.HistoryGetResponse + (*HistoryRenameRequest)(nil), // 40: liveagent.gateway.v1.HistoryRenameRequest + (*HistoryRenameResponse)(nil), // 41: liveagent.gateway.v1.HistoryRenameResponse + (*HistoryPinRequest)(nil), // 42: liveagent.gateway.v1.HistoryPinRequest + (*HistoryPinResponse)(nil), // 43: liveagent.gateway.v1.HistoryPinResponse + (*HistoryShareStatus)(nil), // 44: liveagent.gateway.v1.HistoryShareStatus + (*HistoryShareGetRequest)(nil), // 45: liveagent.gateway.v1.HistoryShareGetRequest + (*HistoryShareGetResponse)(nil), // 46: liveagent.gateway.v1.HistoryShareGetResponse + (*HistoryShareSetRequest)(nil), // 47: liveagent.gateway.v1.HistoryShareSetRequest + (*HistoryShareSetResponse)(nil), // 48: liveagent.gateway.v1.HistoryShareSetResponse + (*HistoryShareResolveRequest)(nil), // 49: liveagent.gateway.v1.HistoryShareResolveRequest + (*HistoryShareResolveResponse)(nil), // 50: liveagent.gateway.v1.HistoryShareResolveResponse + (*HistoryWorkdirsRequest)(nil), // 51: liveagent.gateway.v1.HistoryWorkdirsRequest + (*HistoryWorkdirSummary)(nil), // 52: liveagent.gateway.v1.HistoryWorkdirSummary + (*HistoryWorkdirsResponse)(nil), // 53: liveagent.gateway.v1.HistoryWorkdirsResponse + (*HistoryDeleteRequest)(nil), // 54: liveagent.gateway.v1.HistoryDeleteRequest + (*HistoryDeleteResponse)(nil), // 55: liveagent.gateway.v1.HistoryDeleteResponse + (*HistoryTruncateRequest)(nil), // 56: liveagent.gateway.v1.HistoryTruncateRequest + (*HistoryTruncateResponse)(nil), // 57: liveagent.gateway.v1.HistoryTruncateResponse + (*HistorySyncEvent)(nil), // 58: liveagent.gateway.v1.HistorySyncEvent + (*ProviderListRequest)(nil), // 59: liveagent.gateway.v1.ProviderListRequest + (*ProviderListResponse)(nil), // 60: liveagent.gateway.v1.ProviderListResponse + (*SettingsGetRequest)(nil), // 61: liveagent.gateway.v1.SettingsGetRequest + (*SettingsGetResponse)(nil), // 62: liveagent.gateway.v1.SettingsGetResponse + (*SettingsUpdateRequest)(nil), // 63: liveagent.gateway.v1.SettingsUpdateRequest + (*SettingsUpdateResponse)(nil), // 64: liveagent.gateway.v1.SettingsUpdateResponse + (*SettingsSyncEvent)(nil), // 65: liveagent.gateway.v1.SettingsSyncEvent + (*SkillFilesListRequest)(nil), // 66: liveagent.gateway.v1.SkillFilesListRequest + (*SkillFilesListResponse)(nil), // 67: liveagent.gateway.v1.SkillFilesListResponse + (*SkillMetadataReadRequest)(nil), // 68: liveagent.gateway.v1.SkillMetadataReadRequest + (*SkillMetadataReadResponse)(nil), // 69: liveagent.gateway.v1.SkillMetadataReadResponse + (*SkillTextReadRequest)(nil), // 70: liveagent.gateway.v1.SkillTextReadRequest + (*SkillTextReadResponse)(nil), // 71: liveagent.gateway.v1.SkillTextReadResponse + (*SkillManageRequest)(nil), // 72: liveagent.gateway.v1.SkillManageRequest + (*SkillManageResponse)(nil), // 73: liveagent.gateway.v1.SkillManageResponse + (*FileMentionListRequest)(nil), // 74: liveagent.gateway.v1.FileMentionListRequest + (*FileMentionEntry)(nil), // 75: liveagent.gateway.v1.FileMentionEntry + (*FileMentionListResponse)(nil), // 76: liveagent.gateway.v1.FileMentionListResponse + (*FsRoot)(nil), // 77: liveagent.gateway.v1.FsRoot + (*FsRootsRequest)(nil), // 78: liveagent.gateway.v1.FsRootsRequest + (*FsRootsResponse)(nil), // 79: liveagent.gateway.v1.FsRootsResponse + (*FsListDirsRequest)(nil), // 80: liveagent.gateway.v1.FsListDirsRequest + (*FsDirEntry)(nil), // 81: liveagent.gateway.v1.FsDirEntry + (*FsListDirsResponse)(nil), // 82: liveagent.gateway.v1.FsListDirsResponse + (*FsCreateProjectFolderRequest)(nil), // 83: liveagent.gateway.v1.FsCreateProjectFolderRequest + (*FsCreateProjectFolderResponse)(nil), // 84: liveagent.gateway.v1.FsCreateProjectFolderResponse + (*FsListRequest)(nil), // 85: liveagent.gateway.v1.FsListRequest + (*FsListEntry)(nil), // 86: liveagent.gateway.v1.FsListEntry + (*FsListResponse)(nil), // 87: liveagent.gateway.v1.FsListResponse + (*FsReadEditableTextRequest)(nil), // 88: liveagent.gateway.v1.FsReadEditableTextRequest + (*FsReadEditableTextResponse)(nil), // 89: liveagent.gateway.v1.FsReadEditableTextResponse + (*FsReadWorkspaceImageRequest)(nil), // 90: liveagent.gateway.v1.FsReadWorkspaceImageRequest + (*FsReadWorkspaceImageResponse)(nil), // 91: liveagent.gateway.v1.FsReadWorkspaceImageResponse + (*FsWriteTextRequest)(nil), // 92: liveagent.gateway.v1.FsWriteTextRequest + (*FsWriteTextResponse)(nil), // 93: liveagent.gateway.v1.FsWriteTextResponse + (*FsCreateDirRequest)(nil), // 94: liveagent.gateway.v1.FsCreateDirRequest + (*FsCreateDirResponse)(nil), // 95: liveagent.gateway.v1.FsCreateDirResponse + (*FsRenameRequest)(nil), // 96: liveagent.gateway.v1.FsRenameRequest + (*FsRenameResponse)(nil), // 97: liveagent.gateway.v1.FsRenameResponse + (*FsDeleteRequest)(nil), // 98: liveagent.gateway.v1.FsDeleteRequest + (*FsDeleteResponse)(nil), // 99: liveagent.gateway.v1.FsDeleteResponse + (*PingRequest)(nil), // 100: liveagent.gateway.v1.PingRequest + (*PongResponse)(nil), // 101: liveagent.gateway.v1.PongResponse + (*ErrorResponse)(nil), // 102: liveagent.gateway.v1.ErrorResponse } var file_proto_v1_gateway_proto_depIdxs = []int32{ - 22, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_request:type_name -> liveagent.gateway.v1.ChatRequest - 23, // 1: liveagent.gateway.v1.GatewayEnvelope.cancel_chat:type_name -> liveagent.gateway.v1.CancelChatRequest - 25, // 2: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest - 27, // 3: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest - 30, // 4: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest - 32, // 5: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest - 46, // 6: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest - 48, // 7: liveagent.gateway.v1.GatewayEnvelope.history_truncate:type_name -> liveagent.gateway.v1.HistoryTruncateRequest - 34, // 8: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest - 37, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest - 39, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest - 41, // 11: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest - 43, // 12: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest - 51, // 13: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest - 53, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest - 55, // 15: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest - 58, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest - 60, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest - 62, // 18: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest - 66, // 19: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest - 9, // 20: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest - 70, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest - 72, // 22: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest - 92, // 23: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest - 11, // 24: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest - 13, // 25: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest - 64, // 26: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest - 75, // 27: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest - 15, // 28: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest - 77, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest - 84, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest - 86, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest - 88, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest - 90, // 33: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest - 20, // 34: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest - 80, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest - 82, // 36: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest - 24, // 37: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent - 26, // 38: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse - 28, // 39: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse - 31, // 40: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse - 33, // 41: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse - 47, // 42: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse - 50, // 43: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent - 49, // 44: liveagent.gateway.v1.AgentEnvelope.history_truncate_resp:type_name -> liveagent.gateway.v1.HistoryTruncateResponse - 35, // 45: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse - 38, // 46: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse - 40, // 47: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse - 42, // 48: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse - 45, // 49: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse - 52, // 50: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse - 54, // 51: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse - 56, // 52: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse - 57, // 53: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent - 59, // 54: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse - 61, // 55: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse - 63, // 56: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse - 68, // 57: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse - 10, // 58: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse - 71, // 59: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse - 93, // 60: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse - 74, // 61: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse - 12, // 62: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse - 14, // 63: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse - 65, // 64: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse - 76, // 65: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse - 18, // 66: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse - 19, // 67: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent - 79, // 68: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse - 85, // 69: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse - 87, // 70: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse - 89, // 71: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse - 91, // 72: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse - 21, // 73: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse - 81, // 74: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse - 83, // 75: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse - 94, // 76: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse - 8, // 77: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile - 7, // 78: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 16, // 79: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession - 16, // 80: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession - 17, // 81: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption - 16, // 82: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession - 5, // 83: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel - 7, // 84: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 6, // 85: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls - 0, // 86: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType - 29, // 87: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 88: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 89: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 90: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 36, // 91: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 36, // 92: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 29, // 93: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 44, // 94: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary - 29, // 95: liveagent.gateway.v1.HistoryTruncateResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 29, // 96: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 67, // 97: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry - 69, // 98: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot - 73, // 99: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry - 78, // 100: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry - 4, // 101: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope - 1, // 102: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest - 3, // 103: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope - 2, // 104: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse - 103, // [103:105] is the sub-list for method output_type - 101, // [101:103] is the sub-list for method input_type - 101, // [101:101] is the sub-list for extension type_name - 101, // [101:101] is the sub-list for extension extendee - 0, // [0:101] is the sub-list for field type_name + 28, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_request:type_name -> liveagent.gateway.v1.ChatRequest + 29, // 1: liveagent.gateway.v1.GatewayEnvelope.cancel_chat:type_name -> liveagent.gateway.v1.CancelChatRequest + 33, // 2: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest + 35, // 3: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest + 38, // 4: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest + 40, // 5: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest + 54, // 6: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest + 56, // 7: liveagent.gateway.v1.GatewayEnvelope.history_truncate:type_name -> liveagent.gateway.v1.HistoryTruncateRequest + 42, // 8: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest + 45, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest + 47, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest + 49, // 11: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest + 51, // 12: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest + 59, // 13: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest + 61, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest + 63, // 15: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest + 66, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest + 68, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest + 70, // 18: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest + 74, // 19: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest + 10, // 20: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest + 78, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest + 80, // 22: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest + 100, // 23: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest + 12, // 24: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest + 19, // 25: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest + 72, // 26: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest + 83, // 27: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest + 21, // 28: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest + 85, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest + 92, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest + 94, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest + 96, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest + 98, // 33: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest + 26, // 34: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest + 88, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest + 90, // 36: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest + 14, // 37: liveagent.gateway.v1.GatewayEnvelope.tunnel_control:type_name -> liveagent.gateway.v1.TunnelControlRequest + 15, // 38: liveagent.gateway.v1.GatewayEnvelope.tunnel_control_resp:type_name -> liveagent.gateway.v1.TunnelControlResponse + 18, // 39: liveagent.gateway.v1.GatewayEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame + 30, // 40: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent + 34, // 41: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse + 36, // 42: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse + 39, // 43: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse + 41, // 44: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse + 55, // 45: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse + 58, // 46: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent + 57, // 47: liveagent.gateway.v1.AgentEnvelope.history_truncate_resp:type_name -> liveagent.gateway.v1.HistoryTruncateResponse + 43, // 48: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse + 46, // 49: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse + 48, // 50: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse + 50, // 51: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse + 53, // 52: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse + 60, // 53: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse + 62, // 54: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse + 64, // 55: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse + 65, // 56: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent + 67, // 57: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse + 69, // 58: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse + 71, // 59: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse + 76, // 60: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse + 11, // 61: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse + 79, // 62: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse + 101, // 63: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse + 82, // 64: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse + 13, // 65: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse + 20, // 66: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse + 73, // 67: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse + 84, // 68: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse + 24, // 69: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse + 25, // 70: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent + 87, // 71: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse + 93, // 72: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse + 95, // 73: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse + 97, // 74: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse + 99, // 75: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse + 27, // 76: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse + 89, // 77: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse + 91, // 78: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse + 14, // 79: liveagent.gateway.v1.AgentEnvelope.tunnel_control:type_name -> liveagent.gateway.v1.TunnelControlRequest + 15, // 80: liveagent.gateway.v1.AgentEnvelope.tunnel_control_resp:type_name -> liveagent.gateway.v1.TunnelControlResponse + 18, // 81: liveagent.gateway.v1.AgentEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame + 31, // 82: liveagent.gateway.v1.AgentEnvelope.chat_control:type_name -> liveagent.gateway.v1.ChatControlEvent + 32, // 83: liveagent.gateway.v1.AgentEnvelope.runtime_status:type_name -> liveagent.gateway.v1.RuntimeStatusEvent + 102, // 84: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse + 9, // 85: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile + 8, // 86: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 16, // 87: liveagent.gateway.v1.TunnelControlResponse.tunnels:type_name -> liveagent.gateway.v1.TunnelSummary + 16, // 88: liveagent.gateway.v1.TunnelControlResponse.tunnel:type_name -> liveagent.gateway.v1.TunnelSummary + 0, // 89: liveagent.gateway.v1.TunnelFrame.kind:type_name -> liveagent.gateway.v1.TunnelFrameKind + 17, // 90: liveagent.gateway.v1.TunnelFrame.headers:type_name -> liveagent.gateway.v1.TunnelHeader + 22, // 91: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession + 22, // 92: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession + 23, // 93: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption + 22, // 94: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession + 6, // 95: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel + 8, // 96: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 7, // 97: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls + 1, // 98: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType + 37, // 99: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary + 37, // 100: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 37, // 101: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 37, // 102: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 44, // 103: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 44, // 104: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 37, // 105: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 52, // 106: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary + 37, // 107: liveagent.gateway.v1.HistoryTruncateResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 37, // 108: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 75, // 109: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry + 77, // 110: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot + 81, // 111: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry + 86, // 112: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry + 5, // 113: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope + 2, // 114: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest + 4, // 115: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope + 3, // 116: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse + 115, // [115:117] is the sub-list for method output_type + 113, // [113:115] is the sub-list for method input_type + 113, // [113:113] is the sub-list for extension type_name + 113, // [113:113] is the sub-list for extension extendee + 0, // [0:113] is the sub-list for field type_name } func init() { file_proto_v1_gateway_proto_init() } @@ -7662,6 +8660,9 @@ func file_proto_v1_gateway_proto_init() { (*GatewayEnvelope_GitRequest)(nil), (*GatewayEnvelope_FsReadEditableText)(nil), (*GatewayEnvelope_FsReadWorkspaceImage)(nil), + (*GatewayEnvelope_TunnelControl)(nil), + (*GatewayEnvelope_TunnelControlResp)(nil), + (*GatewayEnvelope_TunnelFrame)(nil), } file_proto_v1_gateway_proto_msgTypes[3].OneofWrappers = []any{ (*AgentEnvelope_ChatEvent)(nil), @@ -7703,16 +8704,21 @@ func file_proto_v1_gateway_proto_init() { (*AgentEnvelope_GitResponse)(nil), (*AgentEnvelope_FsReadEditableTextResp)(nil), (*AgentEnvelope_FsReadWorkspaceImageResp)(nil), + (*AgentEnvelope_TunnelControl)(nil), + (*AgentEnvelope_TunnelControlResp)(nil), + (*AgentEnvelope_TunnelFrame)(nil), + (*AgentEnvelope_ChatControl)(nil), + (*AgentEnvelope_RuntimeStatus)(nil), (*AgentEnvelope_Error)(nil), } - file_proto_v1_gateway_proto_msgTypes[38].OneofWrappers = []any{} + file_proto_v1_gateway_proto_msgTypes[45].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_v1_gateway_proto_rawDesc), len(file_proto_v1_gateway_proto_rawDesc)), - NumEnums: 1, - NumMessages: 94, + NumEnums: 2, + NumMessages: 101, NumExtensions: 0, NumServices: 1, }, diff --git a/crates/agent-gateway/internal/server/grpc.go b/crates/agent-gateway/internal/server/grpc.go index a51f0a494..624ff2993 100644 --- a/crates/agent-gateway/internal/server/grpc.go +++ b/crates/agent-gateway/internal/server/grpc.go @@ -59,6 +59,13 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ defer cancel() go s.heartbeatLoop(ctx, sess) + go func() { + select { + case <-ctx.Done(): + case <-sess.Done(): + cancel() + } + }() sendErrCh := make(chan error, 1) go func() { @@ -71,12 +78,23 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ sendErrCh <- nil cancel() return - case env := <-toAgent: - if err := stream.Send(env); err != nil { + case outbound := <-toAgent: + if outbound == nil || outbound.GatewayEnvelope == nil { + continue + } + select { + case <-outbound.Context().Done(): + outbound.Ack(outbound.Context().Err()) + continue + default: + } + if err := stream.Send(outbound.GatewayEnvelope); err != nil { + outbound.Ack(err) sendErrCh <- err cancel() return } + outbound.Ack(nil) } } }() @@ -111,13 +129,15 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ } func (s *GRPCServer) heartbeatLoop(ctx context.Context, sess *session.AgentSession) { - ticker := time.NewTicker(s.cfg.HeartbeatPeriod) + period := s.heartbeatPeriod() + ticker := time.NewTicker(period) defer ticker.Stop() if !s.sendHeartbeat(sess) { return } + timeout := period * 3 for { select { case <-ctx.Done(): @@ -125,6 +145,9 @@ func (s *GRPCServer) heartbeatLoop(ctx context.Context, sess *session.AgentSessi case <-sess.Done(): return case <-ticker.C: + if s.sm.ClearSessionIfHeartbeatStale(sess, timeout) { + return + } if !s.sendHeartbeat(sess) { return } @@ -132,6 +155,13 @@ func (s *GRPCServer) heartbeatLoop(ctx context.Context, sess *session.AgentSessi } } +func (s *GRPCServer) heartbeatPeriod() time.Duration { + if s.cfg == nil || s.cfg.HeartbeatPeriod <= 0 { + return 30 * time.Second + } + return s.cfg.HeartbeatPeriod +} + func (s *GRPCServer) sendHeartbeat(sess *session.AgentSession) bool { ok, err := sess.TrySendToAgent(&gatewayv1.GatewayEnvelope{ RequestId: "ping-" + uuid.NewString(), diff --git a/crates/agent-gateway/internal/server/http.go b/crates/agent-gateway/internal/server/http.go index 89e96eebb..446e0481b 100644 --- a/crates/agent-gateway/internal/server/http.go +++ b/crates/agent-gateway/internal/server/http.go @@ -24,6 +24,7 @@ func NewHTTPServer(cfg *config.Config, sm *session.Manager) http.Handler { rootMux := http.NewServeMux() rootMux.HandleFunc("GET /healthz", handler.Health()) rootMux.Handle("/ws", NewWebSocketServer(cfg, sm)) + rootMux.HandleFunc("/t/", publicTunnelProxy(sm)) rootMux.HandleFunc("GET /image-proxy", handler.ImageProxy(cfg.RequestTimeout)) rootMux.HandleFunc("GET /api/public/history-shares/{token}", publicHistoryShare(cfg, sm)) @@ -42,6 +43,7 @@ func NewHTTPServer(cfg *config.Config, sm *session.Manager) http.Handler { } fileServer := http.FileServer(http.FS(webFS)) serveIndex := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Content-Type", "text/html; charset=utf-8") http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(indexHTML)) } @@ -57,18 +59,34 @@ func NewHTTPServer(cfg *config.Config, sm *session.Manager) http.Handler { if err == nil { if stat, statErr := file.Stat(); statErr == nil && !stat.IsDir() { _ = file.Close() + if strings.HasPrefix(cleanPath, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } fileServer.ServeHTTP(w, r) return } _ = file.Close() } + if isWebUIStaticAssetPath(cleanPath) { + http.NotFound(w, r) + return + } + serveIndex(w, r) }) return rootMux } +func isWebUIStaticAssetPath(cleanPath string) bool { + cleanPath = strings.TrimSpace(cleanPath) + if cleanPath == "" || cleanPath == "." || cleanPath == "index.html" { + return false + } + return strings.HasPrefix(cleanPath, "assets/") || path.Ext(cleanPath) != "" +} + func publicHistoryShare(cfg *config.Config, sm *session.Manager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := strings.TrimSpace(r.PathValue("token")) diff --git a/crates/agent-gateway/internal/server/http_test.go b/crates/agent-gateway/internal/server/http_test.go index 2f780b98d..7909fc6d8 100644 --- a/crates/agent-gateway/internal/server/http_test.go +++ b/crates/agent-gateway/internal/server/http_test.go @@ -29,6 +29,9 @@ func TestNewHTTPServerServesRootWithoutRedirect(t *testing.T) { if !strings.Contains(rec.Body.String(), "LiveAgent Gateway") { t.Fatalf("expected WebUI index.html, got body %q", rec.Body.String()) } + if cacheControl := rec.Header().Get("Cache-Control"); !strings.Contains(cacheControl, "no-store") { + t.Fatalf("Cache-Control = %q, want no-store for index.html", cacheControl) + } } func TestNewHTTPServerServesSpaFallbackWithoutRedirect(t *testing.T) { @@ -49,6 +52,30 @@ func TestNewHTTPServerServesSpaFallbackWithoutRedirect(t *testing.T) { } } +func TestNewHTTPServerDoesNotFallbackMissingStaticAssetsToIndex(t *testing.T) { + handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) + + for _, target := range []string{ + "http://gateway.test/assets/missing-module.js", + "http://gateway.test/assets/missing-style.css", + "http://gateway.test/missing-icon.svg", + } { + req := httptest.NewRequest(http.MethodGet, target, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("%s status = %d, want %d", target, rec.Code, http.StatusNotFound) + } + if strings.Contains(rec.Body.String(), "LiveAgent Gateway") { + t.Fatalf("%s returned SPA index fallback for a missing static asset", target) + } + if contentType := rec.Header().Get("Content-Type"); strings.Contains(contentType, "text/html") { + t.Fatalf("%s Content-Type = %q, want non-html 404", target, contentType) + } + } +} + func TestPublicHistoryShareResolvesWithoutAuthorization(t *testing.T) { sm := session.NewManager() sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") @@ -69,7 +96,9 @@ func TestPublicHistoryShareResolvesWithoutAuthorization(t *testing.T) { var outbound *gatewayv1.GatewayEnvelope select { - case outbound = <-agentSession.Outbound(): + case delivered := <-agentSession.Outbound(): + delivered.Ack(nil) + outbound = delivered.GatewayEnvelope case <-time.After(time.Second): t.Fatal("timed out waiting for public share request") } @@ -164,7 +193,9 @@ func publicHistoryShareErrorStatusForTest(t *testing.T, code int, message string var outbound *gatewayv1.GatewayEnvelope select { - case outbound = <-agentSession.Outbound(): + case delivered := <-agentSession.Outbound(): + delivered.Ack(nil) + outbound = delivered.GatewayEnvelope case <-time.After(time.Second): t.Fatal("timed out waiting for public share request") } diff --git a/crates/agent-gateway/internal/server/tunnel.go b/crates/agent-gateway/internal/server/tunnel.go new file mode 100644 index 000000000..33fbc97ab --- /dev/null +++ b/crates/agent-gateway/internal/server/tunnel.go @@ -0,0 +1,722 @@ +package server + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/session" + "golang.org/x/net/websocket" +) + +const tunnelBodyChunkSize = 64 * 1024 + +func publicTunnelProxy(sm *session.Manager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if slug, ok := parseTunnelPublicPathWithoutTrailingSlash(r.URL.Path); ok { + target := "/t/" + slug + "/" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + http.Redirect(w, r, target, http.StatusPermanentRedirect) + return + } + + slug, restPath, ok := parseTunnelPublicPath(r.URL.Path) + if !ok { + writeTunnelError(w, http.StatusNotFound, "tunnel not found") + return + } + if r.URL.RawQuery != "" { + restPath += "?" + r.URL.RawQuery + } + + if isWebSocketUpgrade(r) { + serveTunnelWebSocket(w, r, sm, slug, restPath) + return + } + serveTunnelHTTP(w, r, sm, slug, restPath) + } +} + +func serveTunnelHTTP( + w http.ResponseWriter, + r *http.Request, + sm *session.Manager, + slug string, + restPath string, +) { + streamID := "http-" + uuid.NewString() + lease, err := sm.AcquireTunnel(slug, streamID) + if err != nil { + writeTunnelAcquireError(w, err) + return + } + + ctx, cancel := context.WithCancel(r.Context()) + completed := false + defer func() { + cancel() + if !completed { + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + }) + } + lease.Release() + }() + + start := &gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START, + Method: r.Method, + Path: restPath, + Headers: filteredTunnelRequestHeaders(r.Header), + } + if err := sm.SendTunnelFrameToAgent(start); err != nil { + writeTunnelAcquireError(w, err) + return + } + + bodyDone := make(chan struct{}) + go streamTunnelHTTPRequestBody(ctx, sm, lease.TunnelID(), slug, streamID, r.Body, bodyDone) + + responseStarted := false + responseHeadersWritten := false + responseStatus := http.StatusOK + responseHeaders := http.Header{} + responseRewriteKind := tunnelResponseRewriteNone + var responseBody []byte + writeResponseHeaders := func() { + if responseHeadersWritten { + return + } + writeTunnelHTTPHeaders(w, responseHeaders) + w.WriteHeader(responseStatus) + responseHeadersWritten = true + } + writeBufferedResponse := func() { + if responseRewriteKind == tunnelResponseRewriteNone { + return + } + writeResponseHeaders() + if len(responseBody) > 0 { + _, _ = w.Write(responseBody) + responseBody = nil + } + flushTunnelResponse(w) + } + for { + select { + case <-r.Context().Done(): + return + case <-lease.Done(): + if !responseStarted { + writeTunnelError(w, http.StatusBadGateway, "tunnel stream closed") + } else if !responseHeadersWritten { + writeBufferedResponse() + } + return + case <-bodyDone: + bodyDone = nil + case frame := <-lease.Frames(): + if frame == nil { + continue + } + switch frame.GetKind() { + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_START: + if responseStarted { + continue + } + responseStarted = true + status := int(frame.GetStatusCode()) + if status <= 0 { + status = http.StatusOK + } + responseStatus = status + responseHeaders = tunnelResponseHeaders(frame, lease.Tunnel()) + responseRewriteKind = tunnelResponseRewriteKindFor(r.Method, responseStatus, responseHeaders) + if responseRewriteKind == tunnelResponseRewriteNone { + writeResponseHeaders() + flushTunnelResponse(w) + } + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY: + if !responseStarted { + responseStarted = true + responseStatus = http.StatusOK + responseHeaders = http.Header{} + responseRewriteKind = tunnelResponseRewriteNone + writeResponseHeaders() + } + if body := frame.GetBody(); len(body) > 0 { + if responseRewriteKind != tunnelResponseRewriteNone { + if len(responseBody)+len(body) <= tunnelRewriteBodyMaxBytes { + responseBody = append(responseBody, body...) + continue + } + responseRewriteKind = tunnelResponseRewriteNone + writeResponseHeaders() + if len(responseBody) > 0 { + if _, err := w.Write(responseBody); err != nil { + return + } + responseBody = nil + } + } + writeResponseHeaders() + if _, err := w.Write(body); err != nil { + return + } + flushTunnelResponse(w) + } + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END: + if responseRewriteKind != tunnelResponseRewriteNone { + body := responseBody + if rewritten, changed := rewriteTunnelResponseBody(body, lease.Tunnel(), responseRewriteKind); changed { + body = rewritten + responseHeaders.Del("Content-Length") + responseHeaders.Del("Etag") + responseHeaders.Del("ETag") + } + writeResponseHeaders() + if len(body) > 0 { + if _, err := w.Write(body); err != nil { + return + } + } + responseBody = nil + } else if responseStarted && !responseHeadersWritten { + writeResponseHeaders() + } + completed = true + flushTunnelResponse(w) + return + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR: + if !responseStarted { + writeTunnelError(w, http.StatusBadGateway, frame.GetError()) + } else if !responseHeadersWritten { + writeBufferedResponse() + } + return + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL: + if !responseStarted { + writeTunnelError(w, http.StatusServiceUnavailable, "tunnel canceled") + } else if !responseHeadersWritten { + writeBufferedResponse() + } + return + } + } + } +} + +func serveTunnelWebSocket( + w http.ResponseWriter, + r *http.Request, + sm *session.Manager, + slug string, + restPath string, +) { + streamID := "ws-" + uuid.NewString() + lease, err := sm.AcquireTunnel(slug, streamID) + if err != nil { + writeTunnelAcquireError(w, err) + return + } + handlerStarted := false + handler := websocket.Handler(func(ws *websocket.Conn) { + handlerStarted = true + defer lease.Release() + handleTunnelWebSocket(ws, r, sm, lease, slug, streamID, restPath) + }) + handler.ServeHTTP(w, r) + if !handlerStarted { + lease.Release() + } +} + +func handleTunnelWebSocket( + ws *websocket.Conn, + r *http.Request, + sm *session.Manager, + lease *session.TunnelStreamLease, + slug string, + streamID string, + restPath string, +) { + ws.MaxPayloadBytes = 16 * 1024 * 1024 + closed := false + defer func() { + if !closed { + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + }) + } + _ = ws.Close() + }() + + if err := sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_OPEN, + Method: r.Method, + Path: restPath, + Headers: filteredTunnelRequestHeaders(r.Header), + }); err != nil { + return + } + + if !awaitTunnelWebSocketOpen(ws, lease) { + closed = true + return + } + + browserFrames := make(chan *gatewayv1.TunnelFrame, 64) + readerDone := make(chan struct{}) + go func() { + defer close(readerDone) + for { + var body []byte + if err := websocket.Message.Receive(ws, &body); err != nil { + return + } + messageType := "binary" + if utf8.Valid(body) { + messageType = "text" + } + frame := &gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME, + Body: body, + WsMessageType: messageType, + } + select { + case browserFrames <- frame: + case <-lease.Done(): + return + } + } + }() + + for { + select { + case <-lease.Done(): + closed = true + return + case <-readerDone: + closed = true + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: lease.TunnelID(), + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + }) + return + case frame := <-browserFrames: + if frame != nil { + _ = sm.SendTunnelFrameToAgent(frame) + } + case frame := <-lease.Frames(): + if frame == nil { + continue + } + switch frame.GetKind() { + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME: + if strings.EqualFold(frame.GetWsMessageType(), "text") { + if err := websocket.Message.Send(ws, string(frame.GetBody())); err != nil { + return + } + } else { + if err := websocket.Message.Send(ws, frame.GetBody()); err != nil { + return + } + } + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR: + closed = true + return + } + } + } +} + +func awaitTunnelWebSocketOpen(ws *websocket.Conn, lease *session.TunnelStreamLease) bool { + timer := time.NewTimer(30 * time.Second) + defer timer.Stop() + for { + select { + case <-timer.C: + return false + case <-lease.Done(): + return false + case frame := <-lease.Frames(): + if frame == nil { + continue + } + switch frame.GetKind() { + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_OPEN: + return true + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR, + gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE: + if frame.GetError() != "" { + _ = websocket.Message.Send(ws, frame.GetError()) + } + return false + } + } + } +} + +func streamTunnelHTTPRequestBody( + ctx context.Context, + sm *session.Manager, + tunnelID string, + slug string, + streamID string, + body io.ReadCloser, + done chan<- struct{}, +) { + defer close(done) + defer body.Close() + + buffer := make([]byte, tunnelBodyChunkSize) + for { + n, err := body.Read(buffer) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buffer[:n]) + if sendErr := sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: tunnelID, + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY, + Body: chunk, + }); sendErr != nil { + return + } + } + if errors.Is(err, io.EOF) { + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: tunnelID, + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_END, + }) + return + } + if err != nil { + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + TunnelId: tunnelID, + Slug: slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + Error: err.Error(), + }) + return + } + select { + case <-ctx.Done(): + return + default: + } + } +} + +func parseTunnelPublicPath(rawPath string) (string, string, bool) { + if !strings.HasPrefix(rawPath, "/t/") { + return "", "", false + } + trimmed := strings.TrimPrefix(rawPath, "/t/") + parts := strings.SplitN(trimmed, "/", 2) + slug := strings.TrimSpace(parts[0]) + if slug == "" { + return "", "", false + } + if len(parts) == 1 || parts[1] == "" { + return slug, "/", true + } + return slug, "/" + parts[1], true +} + +func parseTunnelPublicPathWithoutTrailingSlash(rawPath string) (string, bool) { + if !strings.HasPrefix(rawPath, "/t/") { + return "", false + } + trimmed := strings.TrimPrefix(rawPath, "/t/") + if trimmed == "" || strings.Contains(trimmed, "/") { + return "", false + } + return strings.TrimSpace(trimmed), strings.TrimSpace(trimmed) != "" +} + +func writeTunnelAcquireError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, session.ErrTunnelNotFound), errors.Is(err, session.ErrTunnelExpired): + writeTunnelError(w, http.StatusNotFound, "tunnel not found") + case errors.Is(err, session.ErrAgentOffline): + writeTunnelError(w, http.StatusServiceUnavailable, "agent offline") + case errors.Is(err, session.ErrTunnelOverLimit): + writeTunnelError(w, http.StatusTooManyRequests, "tunnel connection limit exceeded") + default: + writeTunnelError(w, http.StatusBadGateway, err.Error()) + } +} + +func writeTunnelError(w http.ResponseWriter, status int, message string) { + message = strings.TrimSpace(message) + if message == "" { + message = http.StatusText(status) + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(status) + _, _ = w.Write([]byte(message)) +} + +func isWebSocketUpgrade(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && + strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") +} + +func filteredTunnelRequestHeaders(headers http.Header) []*gatewayv1.TunnelHeader { + return filteredTunnelHeaders(headers, true) +} + +func filteredTunnelResponseHeaders(headers []*gatewayv1.TunnelHeader) http.Header { + out := http.Header{} + for _, header := range headers { + name := http.CanonicalHeaderKey(strings.TrimSpace(header.GetName())) + if name == "" || shouldDropTunnelHeader(name, false) { + continue + } + out.Add(name, header.GetValue()) + } + return out +} + +func filteredTunnelHeaders(headers http.Header, request bool) []*gatewayv1.TunnelHeader { + out := make([]*gatewayv1.TunnelHeader, 0, len(headers)) + for name, values := range headers { + canonical := http.CanonicalHeaderKey(strings.TrimSpace(name)) + if canonical == "" || shouldDropTunnelHeader(canonical, request) { + continue + } + for _, value := range values { + out = append(out, &gatewayv1.TunnelHeader{ + Name: canonical, + Value: value, + }) + } + } + return out +} + +func shouldDropTunnelHeader(name string, request bool) bool { + switch strings.ToLower(name) { + case "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade": + return true + case "host": + return request + default: + return false + } +} + +func writeTunnelResponseHeaders( + w http.ResponseWriter, + frame *gatewayv1.TunnelFrame, + tunnel *gatewayv1.TunnelSummary, +) { + writeTunnelHTTPHeaders(w, tunnelResponseHeaders(frame, tunnel)) +} + +func tunnelResponseHeaders( + frame *gatewayv1.TunnelFrame, + tunnel *gatewayv1.TunnelSummary, +) http.Header { + headers := filteredTunnelResponseHeaders(frame.GetHeaders()) + for name, values := range headers { + rewritten := make([]string, 0, len(values)) + for _, value := range values { + if strings.EqualFold(name, "Location") { + value = rewriteTunnelLocation(value, tunnel) + } + if strings.EqualFold(name, "Set-Cookie") { + value = rewriteTunnelSetCookiePath(value, tunnel) + } + rewritten = append(rewritten, value) + } + headers[name] = rewritten + } + return headers +} + +func writeTunnelHTTPHeaders(w http.ResponseWriter, headers http.Header) { + for name, values := range headers { + for _, value := range values { + w.Header().Add(name, value) + } + } +} + +func flushTunnelResponse(w http.ResponseWriter) { + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } +} + +func rewriteTunnelLocation(value string, tunnel *gatewayv1.TunnelSummary) string { + if tunnel == nil { + return value + } + target, err := url.Parse(tunnel.GetTargetUrl()) + if err != nil || target.Host == "" { + return value + } + publicPrefix := "/t/" + tunnel.GetSlug() + parsed, err := url.Parse(value) + if err != nil { + return value + } + if parsed.IsAbs() { + if !strings.EqualFold(parsed.Scheme, target.Scheme) || !strings.EqualFold(parsed.Host, target.Host) { + return value + } + path := stripTunnelTargetBasePath(parsed.EscapedPath(), target.EscapedPath()) + if path == "" { + path = "/" + } + if parsed.RawQuery != "" { + path += "?" + parsed.RawQuery + } + if parsed.Fragment != "" { + path += "#" + parsed.EscapedFragment() + } + return publicPrefix + path + } + if strings.HasPrefix(value, "/") { + path := stripTunnelTargetBasePath(parsed.EscapedPath(), target.EscapedPath()) + if path == "" { + path = "/" + } + if parsed.RawQuery != "" { + path += "?" + parsed.RawQuery + } + if parsed.Fragment != "" { + path += "#" + parsed.EscapedFragment() + } + return publicPrefix + path + } + return value +} + +func rewriteTunnelSetCookiePath(value string, tunnel *gatewayv1.TunnelSummary) string { + if tunnel == nil || tunnel.GetSlug() == "" { + return value + } + parts := strings.Split(value, ";") + targetBasePath := "/" + if target, err := url.Parse(tunnel.GetTargetUrl()); err == nil { + targetBasePath = target.EscapedPath() + } + for index, part := range parts { + trimmed := strings.TrimSpace(part) + if !strings.HasPrefix(strings.ToLower(trimmed), "path=") { + continue + } + cookiePath := strings.TrimSpace(trimmed[len("path="):]) + if cookiePath == "" { + cookiePath = "/" + } + rest := stripTunnelTargetBasePath(cookiePath, targetBasePath) + if rest == "" { + rest = "/" + } + prefix := "" + if leading := len(part) - len(strings.TrimLeft(part, " \t")); leading > 0 { + prefix = part[:leading] + } + parts[index] = fmt.Sprintf("%sPath=/t/%s%s", prefix, tunnel.GetSlug(), rest) + } + return strings.Join(parts, ";") +} + +func stripTunnelTargetBasePath(pathValue string, basePath string) string { + pathValue = normalizeTunnelPath(pathValue) + basePath = normalizeTunnelPath(basePath) + if basePath == "/" { + return pathValue + } + if pathValue == basePath { + return "/" + } + if strings.HasPrefix(pathValue, strings.TrimRight(basePath, "/")+"/") { + return strings.TrimPrefix(pathValue, strings.TrimRight(basePath, "/")) + } + return pathValue +} + +func normalizeTunnelPath(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "/" + } + if !strings.HasPrefix(value, "/") { + value = "/" + value + } + return value +} + +func publicBaseURLFromHTTPRequest(r *http.Request) string { + if r == nil { + return "" + } + scheme := forwardedHeaderFirst(r.Header.Get("X-Forwarded-Proto")) + if scheme == "" { + if r.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + host := forwardedHeaderFirst(r.Header.Get("X-Forwarded-Host")) + if host == "" { + host = r.Host + } + if host == "" { + return "" + } + return scheme + "://" + host +} + +func forwardedHeaderFirst(value string) string { + first := strings.TrimSpace(strings.Split(value, ",")[0]) + return strings.TrimSpace(first) +} diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite.go b/crates/agent-gateway/internal/server/tunnel_rewrite.go new file mode 100644 index 000000000..b6b743be5 --- /dev/null +++ b/crates/agent-gateway/internal/server/tunnel_rewrite.go @@ -0,0 +1,229 @@ +package server + +import ( + "mime" + "net/http" + "net/url" + "regexp" + "strings" + "unicode/utf8" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +const tunnelRewriteBodyMaxBytes = 4 * 1024 * 1024 + +type tunnelResponseRewriteKind int + +const ( + tunnelResponseRewriteNone tunnelResponseRewriteKind = iota + tunnelResponseRewriteHTML + tunnelResponseRewriteCSS + tunnelResponseRewriteJavaScript +) + +var ( + tunnelHTMLQuotedAttrURLPattern = regexp.MustCompile(`(?i)(\b(?:href|src|action|poster|data|formaction)\s*=\s*)(["'])([^"']+)(["'])`) + tunnelHTMLBareAttrURLPattern = regexp.MustCompile(`(?i)(\b(?:href|src|action|poster|data|formaction)\s*=\s*)([^\s"'<>]+)`) + tunnelJSQuotedURLPattern = regexp.MustCompile(`(["'])(/[^"'\\]*)(["'])`) + tunnelCSSURLPattern = regexp.MustCompile(`(?i)(url\(\s*)(["']?)([^"')]+)(["']?\s*\))`) +) + +func tunnelResponseRewriteKindFor( + method string, + status int, + headers http.Header, +) tunnelResponseRewriteKind { + if strings.EqualFold(strings.TrimSpace(method), http.MethodHead) { + return tunnelResponseRewriteNone + } + if status < http.StatusOK || + status == http.StatusNoContent || + status == http.StatusNotModified { + return tunnelResponseRewriteNone + } + if strings.TrimSpace(headers.Get("Content-Encoding")) != "" { + return tunnelResponseRewriteNone + } + + contentType := strings.TrimSpace(headers.Get("Content-Type")) + if contentType == "" { + return tunnelResponseRewriteNone + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType = contentType + } + mediaType = strings.ToLower(strings.TrimSpace(mediaType)) + + switch { + case mediaType == "text/html" || mediaType == "application/xhtml+xml": + return tunnelResponseRewriteHTML + case mediaType == "text/css": + return tunnelResponseRewriteCSS + case mediaType == "text/javascript", + mediaType == "application/javascript", + mediaType == "application/x-javascript", + mediaType == "text/ecmascript", + mediaType == "application/ecmascript", + strings.HasSuffix(mediaType, "+javascript"): + return tunnelResponseRewriteJavaScript + default: + return tunnelResponseRewriteNone + } +} + +func rewriteTunnelResponseBody( + body []byte, + tunnel *gatewayv1.TunnelSummary, + kind tunnelResponseRewriteKind, +) ([]byte, bool) { + if len(body) == 0 || kind == tunnelResponseRewriteNone || tunnelPublicPathPrefix(tunnel) == "" { + return body, false + } + if !utf8.Valid(body) { + return body, false + } + + original := string(body) + rewritten := original + switch kind { + case tunnelResponseRewriteHTML: + rewritten = rewriteTunnelHTMLBody(rewritten, tunnel) + case tunnelResponseRewriteCSS: + rewritten = rewriteTunnelCSSBody(rewritten, tunnel) + case tunnelResponseRewriteJavaScript: + rewritten = rewriteTunnelJavaScriptBody(rewritten, tunnel) + } + if rewritten == original { + return body, false + } + return []byte(rewritten), true +} + +func rewriteTunnelHTMLBody(input string, tunnel *gatewayv1.TunnelSummary) string { + input = tunnelHTMLQuotedAttrURLPattern.ReplaceAllStringFunc(input, func(match string) string { + parts := tunnelHTMLQuotedAttrURLPattern.FindStringSubmatch(match) + if len(parts) != 5 { + return match + } + rewritten := rewriteTunnelBodyURL(parts[3], tunnel) + if rewritten == parts[3] { + return match + } + return parts[1] + parts[2] + rewritten + parts[4] + }) + + return tunnelHTMLBareAttrURLPattern.ReplaceAllStringFunc(input, func(match string) string { + parts := tunnelHTMLBareAttrURLPattern.FindStringSubmatch(match) + if len(parts) != 3 { + return match + } + rewritten := rewriteTunnelBodyURL(parts[2], tunnel) + if rewritten == parts[2] { + return match + } + return parts[1] + rewritten + }) +} + +func rewriteTunnelCSSBody(input string, tunnel *gatewayv1.TunnelSummary) string { + return tunnelCSSURLPattern.ReplaceAllStringFunc(input, func(match string) string { + parts := tunnelCSSURLPattern.FindStringSubmatch(match) + if len(parts) != 5 { + return match + } + rewritten := rewriteTunnelBodyURL(strings.TrimSpace(parts[3]), tunnel) + if rewritten == strings.TrimSpace(parts[3]) { + return match + } + return parts[1] + parts[2] + rewritten + parts[4] + }) +} + +func rewriteTunnelJavaScriptBody(input string, tunnel *gatewayv1.TunnelSummary) string { + return tunnelJSQuotedURLPattern.ReplaceAllStringFunc(input, func(match string) string { + parts := tunnelJSQuotedURLPattern.FindStringSubmatch(match) + if len(parts) != 4 || parts[1] != parts[3] { + return match + } + rewritten := rewriteTunnelBodyURL(parts[2], tunnel) + if rewritten == parts[2] { + return match + } + return parts[1] + rewritten + parts[1] + }) +} + +func rewriteTunnelBodyURL(value string, tunnel *gatewayv1.TunnelSummary) string { + prefix := tunnelPublicPathPrefix(tunnel) + if prefix == "" { + return value + } + trimmed := strings.TrimSpace(value) + if trimmed == "" || + strings.HasPrefix(trimmed, "#") || + strings.HasPrefix(trimmed, "//") { + return value + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return value + } + target, targetErr := url.Parse(tunnel.GetTargetUrl()) + if parsed.IsAbs() { + if targetErr != nil || target.Host == "" { + return value + } + if !strings.EqualFold(parsed.Scheme, target.Scheme) || + !strings.EqualFold(parsed.Host, target.Host) { + return value + } + path := stripTunnelTargetBasePath(parsed.EscapedPath(), target.EscapedPath()) + return appendTunnelURLQueryAndFragment(prefix+pathOrRoot(path), parsed) + } + if !strings.HasPrefix(trimmed, "/") { + return value + } + if trimmed == prefix || strings.HasPrefix(trimmed, prefix+"/") { + return value + } + + path := parsed.EscapedPath() + if targetErr == nil && target.Host != "" { + path = stripTunnelTargetBasePath(path, target.EscapedPath()) + } + return appendTunnelURLQueryAndFragment(prefix+pathOrRoot(path), parsed) +} + +func tunnelPublicPathPrefix(tunnel *gatewayv1.TunnelSummary) string { + if tunnel == nil { + return "" + } + slug := strings.TrimSpace(tunnel.GetSlug()) + if slug == "" { + return "" + } + return "/t/" + slug +} + +func pathOrRoot(path string) string { + if strings.TrimSpace(path) == "" { + return "/" + } + return path +} + +func appendTunnelURLQueryAndFragment(path string, parsed *url.URL) string { + if parsed == nil { + return path + } + if parsed.RawQuery != "" { + path += "?" + parsed.RawQuery + } + if parsed.Fragment != "" { + path += "#" + parsed.EscapedFragment() + } + return path +} diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite_test.go b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go new file mode 100644 index 000000000..3d9d45136 --- /dev/null +++ b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go @@ -0,0 +1,269 @@ +package server + +import ( + "net/http" + "strings" + "testing" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func TestTunnelResponseRewriteKindFor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + status int + headers http.Header + want tunnelResponseRewriteKind + }{ + { + name: "html", + method: http.MethodGet, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"text/html; charset=utf-8"}, + }, + want: tunnelResponseRewriteHTML, + }, + { + name: "javascript", + method: http.MethodGet, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"application/javascript"}, + }, + want: tunnelResponseRewriteJavaScript, + }, + { + name: "css", + method: http.MethodGet, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"text/css"}, + }, + want: tunnelResponseRewriteCSS, + }, + { + name: "compressed response", + method: http.MethodGet, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"text/html; charset=utf-8"}, + "Content-Encoding": []string{"gzip"}, + }, + want: tunnelResponseRewriteNone, + }, + { + name: "head request", + method: http.MethodHead, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"text/html; charset=utf-8"}, + }, + want: tunnelResponseRewriteNone, + }, + { + name: "json", + method: http.MethodGet, + status: http.StatusOK, + headers: http.Header{ + "Content-Type": []string{"application/json"}, + }, + want: tunnelResponseRewriteNone, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tunnelResponseRewriteKindFor(tt.method, tt.status, tt.headers); got != tt.want { + t.Fatalf("tunnelResponseRewriteKindFor() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRewriteTunnelHTMLBodyPrefixesRootRelativeAttributes(t *testing.T) { + t.Parallel() + + tunnel := tunnelRewriteTestSummary() + input := strings.Join([]string{ + ``, + ``, + `
`, + `health`, + `cdn`, + `external`, + `already`, + `absolute target`, + }, "\n") + + body, changed := rewriteTunnelResponseBody([]byte(input), tunnel, tunnelResponseRewriteHTML) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a change") + } + output := string(body) + + assertContains(t, output, `href="/t/test-slug/styles.css"`) + assertContains(t, output, `src='/t/test-slug/app.js'`) + assertContains(t, output, `action=/t/test-slug/api/messages`) + assertContains(t, output, `href="/t/test-slug/api/health?check=1#ready"`) + assertContains(t, output, `href="//cdn.example.com/lib.js"`) + assertContains(t, output, `href="https://example.com/page"`) + assertContains(t, output, `href="/t/test-slug/already"`) + assertContains(t, output, `href="/t/test-slug/api/showcase"`) + assertNotContains(t, output, `/t/test-slug/t/test-slug`) +} + +func TestRewriteTunnelBodyStripsTargetBasePath(t *testing.T) { + t.Parallel() + + tunnel := &gatewayv1.TunnelSummary{ + Slug: "base-slug", + TargetUrl: "http://127.0.0.1:3100/app", + } + input := strings.Join([]string{ + ``, + ``, + `root api`, + }, "\n") + + body, changed := rewriteTunnelResponseBody([]byte(input), tunnel, tunnelResponseRewriteHTML) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a change") + } + output := string(body) + + assertContains(t, output, `src="/t/base-slug/assets/main.js"`) + assertContains(t, output, `href="/t/base-slug/styles.css"`) + assertContains(t, output, `href="/t/base-slug/api/health"`) + assertNotContains(t, output, `/t/base-slug/app/`) + + jsBody, changed := rewriteTunnelResponseBody( + []byte(`fetch("/app/api/health?check=1#ready")`), + tunnel, + tunnelResponseRewriteJavaScript, + ) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a JavaScript change") + } + assertContains(t, string(jsBody), `fetch("/t/base-slug/api/health?check=1#ready")`) + + cssBody, changed := rewriteTunnelResponseBody( + []byte(`body { background: url(/app/images/bg.png); }`), + tunnel, + tunnelResponseRewriteCSS, + ) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a CSS change") + } + assertContains(t, string(cssBody), `url(/t/base-slug/images/bg.png)`) +} + +func TestRewriteTunnelJavaScriptBodyPrefixesRootRelativeStrings(t *testing.T) { + t.Parallel() + + tunnel := tunnelRewriteTestSummary() + input := strings.Join([]string{ + `requestJson('/api/showcase')`, + `fetch("/api/health?check=1")`, + `const root = "/"`, + `const external = "https://example.com/api"`, + `const cdn = "//cdn.example.com/app.js"`, + `const already = "/t/test-slug/api/health"`, + }, "\n") + + body, changed := rewriteTunnelResponseBody([]byte(input), tunnel, tunnelResponseRewriteJavaScript) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a change") + } + output := string(body) + + assertContains(t, output, `requestJson('/t/test-slug/api/showcase')`) + assertContains(t, output, `fetch("/t/test-slug/api/health?check=1")`) + assertContains(t, output, `const root = "/t/test-slug/"`) + assertContains(t, output, `const external = "https://example.com/api"`) + assertContains(t, output, `const cdn = "//cdn.example.com/app.js"`) + assertContains(t, output, `const already = "/t/test-slug/api/health"`) + assertNotContains(t, output, `/t/test-slug/t/test-slug`) +} + +func TestRewriteTunnelCSSBodyPrefixesRootRelativeURLs(t *testing.T) { + t.Parallel() + + tunnel := tunnelRewriteTestSummary() + input := strings.Join([]string{ + `body { background: url(/images/bg.png); }`, + `.icon { mask-image: url('/icons/check.svg'); }`, + `.remote { background: url("https://example.com/bg.png"); }`, + `.cdn { background: url("//cdn.example.com/bg.png"); }`, + `.already { background: url(/t/test-slug/images/bg.png); }`, + }, "\n") + + body, changed := rewriteTunnelResponseBody([]byte(input), tunnel, tunnelResponseRewriteCSS) + if !changed { + t.Fatal("rewriteTunnelResponseBody() did not report a change") + } + output := string(body) + + assertContains(t, output, `url(/t/test-slug/images/bg.png)`) + assertContains(t, output, `url('/t/test-slug/icons/check.svg')`) + assertContains(t, output, `url("https://example.com/bg.png")`) + assertContains(t, output, `url("//cdn.example.com/bg.png")`) + assertContains(t, output, `url(/t/test-slug/images/bg.png)`) + assertNotContains(t, output, `/t/test-slug/t/test-slug`) +} + +func TestParseTunnelPublicPathWithoutTrailingSlash(t *testing.T) { + t.Parallel() + + slug, ok := parseTunnelPublicPathWithoutTrailingSlash("/t/test-slug") + if !ok || slug != "test-slug" { + t.Fatalf("parseTunnelPublicPathWithoutTrailingSlash() = %q, %v", slug, ok) + } + + for _, path := range []string{"/t/test-slug/", "/t/test-slug/api", "/t/", "/api/test-slug"} { + if slug, ok := parseTunnelPublicPathWithoutTrailingSlash(path); ok { + t.Fatalf("parseTunnelPublicPathWithoutTrailingSlash(%q) = %q, true; want false", path, slug) + } + } +} + +func TestRewriteTunnelLocationPreservesQueryAndFragment(t *testing.T) { + t.Parallel() + + tunnel := tunnelRewriteTestSummary() + if got := rewriteTunnelLocation("/api/health?check=1#ready", tunnel); got != "/t/test-slug/api/health?check=1#ready" { + t.Fatalf("rewriteTunnelLocation root path = %q", got) + } + if got := rewriteTunnelLocation("http://127.0.0.1:3100/api/showcase#item", tunnel); got != "/t/test-slug/api/showcase#item" { + t.Fatalf("rewriteTunnelLocation absolute target = %q", got) + } + if got := rewriteTunnelLocation("https://example.com/api#item", tunnel); got != "https://example.com/api#item" { + t.Fatalf("rewriteTunnelLocation external = %q", got) + } +} + +func tunnelRewriteTestSummary() *gatewayv1.TunnelSummary { + return &gatewayv1.TunnelSummary{ + Slug: "test-slug", + TargetUrl: "http://127.0.0.1:3100", + } +} + +func assertContains(t *testing.T, value string, needle string) { + t.Helper() + if !strings.Contains(value, needle) { + t.Fatalf("expected output to contain %q, got:\n%s", needle, value) + } +} + +func assertNotContains(t *testing.T, value string, needle string) { + t.Helper() + if strings.Contains(value, needle) { + t.Fatalf("expected output not to contain %q, got:\n%s", needle, value) + } +} diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index 56f87c440..844e291ad 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -269,9 +269,19 @@ func (c *websocketConnection) startChatEventForwarder() { if c.hasActiveChatRequest(event.RequestID) { continue } - if err := c.writeConversationEvent(websocketChatEventPayload(event.Event, event.Seq, event.Workdir)); err != nil { - c.close() - return + if event.Control != nil { + if err := c.writeEnvelope(websocketEnvelope{ + Type: "conversation.control", + Payload: websocketChatControlPayload(event.Control, event.Seq, event.Workdir), + }); err != nil { + c.close() + return + } + } else if event.Event != nil { + if err := c.writeConversationEvent(websocketChatEventPayload(event.Event, event.Seq, event.Workdir)); err != nil { + c.close() + return + } } } } @@ -431,6 +441,41 @@ func (c *websocketConnection) awaitAgentResponse( return awaitAgentUnaryResponse(ctx, c.sm, requestID, envelope) } +func (c *websocketConnection) sendToAgent(envelope *gatewayv1.GatewayEnvelope) error { + timeout := c.cfg.WebSocketWriteTimeout + if timeout <= 0 { + timeout = 10 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + go func() { + select { + case <-c.done: + cancel() + case <-ctx.Done(): + } + }() + + return c.sm.SendToAgentContext(ctx, envelope) +} + +func (c *websocketConnection) chatStartTimeout() time.Duration { + timeout := c.cfg.ChatStartTimeout + if timeout <= 0 { + timeout = 15 * time.Second + } + return timeout +} + +func (c *websocketConnection) chatRenderStartTimeout() time.Duration { + timeout := c.cfg.ChatRenderStartTimeout + if timeout <= 0 { + timeout = 45 * time.Second + } + return timeout +} + func (c *websocketConnection) writeResponse(requestID string, payload any) error { return c.writeEnvelope(websocketEnvelope{ ID: requestID, @@ -455,6 +500,14 @@ func (c *websocketConnection) writeChatEvent(requestID string, payload any) erro }) } +func (c *websocketConnection) writeChatControl(requestID string, payload any) error { + return c.writeEnvelope(websocketEnvelope{ + ID: requestID, + Type: "chat.control", + Payload: payload, + }) +} + func (c *websocketConnection) writeHistoryEvent(payload any) error { return c.writeEnvelope(websocketEnvelope{ Type: "history.event", diff --git a/crates/agent-gateway/internal/server/websocket_chat_handlers.go b/crates/agent-gateway/internal/server/websocket_chat_handlers.go index b050b3623..63abbea85 100644 --- a/crates/agent-gateway/internal/server/websocket_chat_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_chat_handlers.go @@ -8,6 +8,7 @@ import ( "github.com/liveagent/agent-gateway/internal/handler" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/session" ) func (c *websocketConnection) handleChatStart(req websocketRequest) { @@ -34,12 +35,17 @@ func (c *websocketConnection) handleChatStart(req websocketRequest) { _ = c.writeError(req.ID, "message is required") return } - if !c.sm.IsOnline() { + status := c.sm.Status() + if !status.Online { _ = c.writeError(req.ID, "agent offline") return } + if !status.ChatRuntimeReady { + _ = c.writeError(req.ID, "Desktop chat runtime is not ready. Please retry.") + return + } - snapshot, created, err := c.sm.StartChatRunWithClientRequest( + snapshot, created, err := c.sm.StartPendingChatRunWithClientRequest( req.ID, body.ConversationID, body.ClientRequestID, @@ -76,7 +82,7 @@ func (c *websocketConnection) handleChatStart(req websocketRequest) { defer c.releaseActiveChat(responseID) if created { - if err := c.sm.SendToAgent(&gatewayv1.GatewayEnvelope{ + if err := c.sendToAgent(&gatewayv1.GatewayEnvelope{ RequestId: sourceRequestID, Timestamp: time.Now().Unix(), Payload: &gatewayv1.GatewayEnvelope_ChatRequest{ @@ -99,6 +105,11 @@ func (c *websocketConnection) handleChatStart(req websocketRequest) { } } + startTimer := time.NewTimer(c.chatStartTimeout()) + defer startTimer.Stop() + renderStartTimer := time.NewTimer(c.chatRenderStartTimeout()) + defer renderStartTimer.Stop() + // Do not enforce a hard timeout for streaming chat requests. The GUI path can run // multiple compaction rounds stably; WebUI should behave the same and only stop // when the user cancels, the connection closes, or the agent returns done/error. @@ -109,25 +120,32 @@ func (c *websocketConnection) handleChatStart(req websocketRequest) { case <-ctx.Done(): _ = c.writeError(responseID, websocketErrorMessage(ctx.Err())) return + case <-startTimer.C: + c.sm.FailStartingChatRun( + sourceRequestID, + "Desktop backend did not accept the remote chat request. Please retry.", + ) + case <-renderStartTimer.C: + c.sm.FailUnstartedChatRun( + sourceRequestID, + "Desktop app accepted the remote chat request but did not start it. Please retry.", + ) case <-eventDone: return case event, ok := <-eventCh: if !ok { return } - chatEvent := event.Event - if chatEvent == nil { - continue - } - if chatEvent.GetConversationId() != "" { - body.ConversationID = strings.TrimSpace(chatEvent.GetConversationId()) + if eventConversationID(event) != "" { + body.ConversationID = eventConversationID(event) c.updateActiveChatConversationID(responseID, body.ConversationID) } - if err := c.writeChatEvent(responseID, websocketChatEventPayload(chatEvent, event.Seq, event.Workdir)); err != nil { + terminal, err := c.writeChatBroadcastEvent(responseID, event) + if err != nil { c.close() return } - if chatEvent.GetType() == gatewayv1.ChatEvent_DONE || chatEvent.GetType() == gatewayv1.ChatEvent_ERROR { + if terminal { return } } @@ -178,6 +196,11 @@ func (c *websocketConnection) handleChatResume(req websocketRequest) { c.registerActiveChat(responseID, snapshot.RequestID, snapshot.ConversationID, cancel) defer c.releaseActiveChat(responseID) + startTimer := time.NewTimer(c.chatStartTimeout()) + defer startTimer.Stop() + renderStartTimer := time.NewTimer(c.chatRenderStartTimeout()) + defer renderStartTimer.Stop() + if snapshot.Done && snapshot.LatestSeq <= body.AfterSeq { payload := map[string]any{ "type": "done", @@ -199,24 +222,31 @@ func (c *websocketConnection) handleChatResume(req websocketRequest) { case <-ctx.Done(): _ = c.writeError(responseID, websocketErrorMessage(ctx.Err())) return + case <-startTimer.C: + c.sm.FailStartingChatRun( + snapshot.RequestID, + "Desktop backend did not accept the remote chat request. Please retry.", + ) + case <-renderStartTimer.C: + c.sm.FailUnstartedChatRun( + snapshot.RequestID, + "Desktop app accepted the remote chat request but did not start it. Please retry.", + ) case <-eventDone: return case event, ok := <-eventCh: if !ok { return } - chatEvent := event.Event - if chatEvent == nil { - continue - } - if chatEvent.GetConversationId() != "" { - c.updateActiveChatConversationID(responseID, strings.TrimSpace(chatEvent.GetConversationId())) + if conversationID := eventConversationID(event); conversationID != "" { + c.updateActiveChatConversationID(responseID, conversationID) } - if err := c.writeChatEvent(responseID, websocketChatEventPayload(chatEvent, event.Seq, event.Workdir)); err != nil { + terminal, err := c.writeChatBroadcastEvent(responseID, event) + if err != nil { c.close() return } - if chatEvent.GetType() == gatewayv1.ChatEvent_DONE || chatEvent.GetType() == gatewayv1.ChatEvent_ERROR { + if terminal { return } } @@ -279,15 +309,12 @@ func (c *websocketConnection) handleChatAttach(req websocketRequest) { if !ok { return } - chatEvent := event.Event - if chatEvent == nil { - continue - } - if err := c.writeChatEvent(req.ID, websocketChatEventPayload(chatEvent, event.Seq, event.Workdir)); err != nil { + terminal, err := c.writeChatBroadcastEvent(req.ID, event) + if err != nil { c.close() return } - if chatEvent.GetType() == gatewayv1.ChatEvent_DONE || chatEvent.GetType() == gatewayv1.ChatEvent_ERROR { + if terminal { return } } @@ -328,7 +355,7 @@ func (c *websocketConnection) handleChatCancel(req websocketRequest) { return } - if err := c.sm.SendToAgent(&gatewayv1.GatewayEnvelope{ + if err := c.sendToAgent(&gatewayv1.GatewayEnvelope{ RequestId: req.ID, Timestamp: time.Now().Unix(), Payload: &gatewayv1.GatewayEnvelope_CancelChat{ @@ -393,6 +420,87 @@ func (c *websocketConnection) cancelActiveChatsByConversation(conversationID str } } +func (c *websocketConnection) writeChatBroadcastEvent( + requestID string, + event *session.ChatBroadcastEvent, +) (bool, error) { + if event == nil { + return false, nil + } + if event.Control != nil { + payload := websocketChatControlPayload(event.Control, event.Seq, event.Workdir) + if err := c.writeChatControl(requestID, payload); err != nil { + return false, err + } + return isTerminalChatControlPayload(event.Control), nil + } + if event.Event == nil { + return false, nil + } + if err := c.writeChatEvent( + requestID, + websocketChatEventPayload(event.Event, event.Seq, event.Workdir), + ); err != nil { + return false, err + } + return event.Event.GetType() == gatewayv1.ChatEvent_DONE || + event.Event.GetType() == gatewayv1.ChatEvent_ERROR, nil +} + +func eventConversationID(event *session.ChatBroadcastEvent) string { + if event == nil { + return "" + } + if event.Control != nil { + return strings.TrimSpace(event.Control.GetConversationId()) + } + if event.Event != nil { + return strings.TrimSpace(event.Event.GetConversationId()) + } + return "" +} + +func isTerminalChatControlPayload(control *gatewayv1.ChatControlEvent) bool { + switch strings.TrimSpace(control.GetState()) { + case "completed", "failed", "cancelled": + return true + default: + return false + } +} + +func websocketChatControlPayload( + control *gatewayv1.ChatControlEvent, + seq int64, + workdirInput ...string, +) map[string]any { + payload := map[string]any{ + "type": strings.TrimSpace(control.GetType()), + "request_id": strings.TrimSpace(control.GetRequestId()), + "client_request_id": strings.TrimSpace(control.GetClientRequestId()), + "conversation_id": strings.TrimSpace(control.GetConversationId()), + "run_epoch": control.GetRunEpoch(), + "state": strings.TrimSpace(control.GetState()), + } + if seq > 0 { + payload["seq"] = seq + } else if control.GetSeq() > 0 { + payload["seq"] = control.GetSeq() + } + if errorCode := strings.TrimSpace(control.GetErrorCode()); errorCode != "" { + payload["error_code"] = errorCode + } + if message := strings.TrimSpace(control.GetMessage()); message != "" { + payload["message"] = message + } + if len(workdirInput) > 0 { + if workdir := strings.TrimSpace(workdirInput[0]); workdir != "" { + payload["workdir"] = workdir + } + } + return payload +} + func websocketChatEventPayload(event *gatewayv1.ChatEvent, seq int64, workdirInput ...string) map[string]any { payload := map[string]any{ "type": websocketChatEventType(event.GetType()), diff --git a/crates/agent-gateway/internal/server/websocket_roundtrip.go b/crates/agent-gateway/internal/server/websocket_roundtrip.go index 3d7836aa6..c2723c57f 100644 --- a/crates/agent-gateway/internal/server/websocket_roundtrip.go +++ b/crates/agent-gateway/internal/server/websocket_roundtrip.go @@ -53,7 +53,7 @@ func awaitAgentUnaryResponse( } defer cleanup() - if err := sm.SendToAgent(envelope); err != nil { + if err := sm.SendToAgentContext(ctx, envelope); err != nil { return nil, err } diff --git a/crates/agent-gateway/internal/server/websocket_routes.go b/crates/agent-gateway/internal/server/websocket_routes.go index 823244479..60ad324dd 100644 --- a/crates/agent-gateway/internal/server/websocket_routes.go +++ b/crates/agent-gateway/internal/server/websocket_routes.go @@ -51,6 +51,10 @@ var websocketRequestHandlers = map[string]websocketRequestHandler{ "terminal.close": (*websocketConnection).handleTerminalRequest, "terminal.close_project": (*websocketConnection).handleTerminalRequest, "terminal.detach": (*websocketConnection).handleTerminalDetach, + "tunnel.list": (*websocketConnection).handleTunnelList, + "tunnel.create": (*websocketConnection).handleTunnelCreate, + "tunnel.update": (*websocketConnection).handleTunnelUpdate, + "tunnel.close": (*websocketConnection).handleTunnelClose, "git.status": (*websocketConnection).handleGitRequest, "git.branches": (*websocketConnection).handleGitRequest, "git.init": (*websocketConnection).handleGitRequest, diff --git a/crates/agent-gateway/internal/server/websocket_routes_test.go b/crates/agent-gateway/internal/server/websocket_routes_test.go index e7e8a2d8e..3b275b626 100644 --- a/crates/agent-gateway/internal/server/websocket_routes_test.go +++ b/crates/agent-gateway/internal/server/websocket_routes_test.go @@ -1,6 +1,9 @@ package server -import "testing" +import ( + "encoding/json" + "testing" +) func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { t.Parallel() @@ -52,6 +55,10 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { "terminal.close", "terminal.close_project", "terminal.detach", + "tunnel.list", + "tunnel.create", + "tunnel.update", + "tunnel.close", "git.status", "git.branches", "git.init", @@ -103,3 +110,50 @@ func TestDecodeWebSocketPayloadRejectsUnknownFields(t *testing.T) { t.Fatal("expected unknown payload field to be rejected") } } + +func TestTunnelTTLFromPayloadDefaultsOnlyWhenOmitted(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw json.RawMessage + camelValue uint32 + snakeValue uint32 + want uint32 + }{ + { + name: "omitted", + raw: json.RawMessage(`{"targetUrl":"http://localhost:3000"}`), + camelValue: 0, + snakeValue: 0, + want: websocketDefaultTunnelTTLSeconds, + }, + { + name: "camel explicit infinite", + raw: json.RawMessage(`{"targetUrl":"http://localhost:3000","ttlSeconds":0}`), + camelValue: 0, + snakeValue: 0, + want: 0, + }, + { + name: "snake explicit infinite", + raw: json.RawMessage(`{"target_url":"http://localhost:3000","ttl_seconds":0}`), + camelValue: 0, + snakeValue: 0, + want: 0, + }, + { + name: "camel finite", + raw: json.RawMessage(`{"targetUrl":"http://localhost:3000","ttlSeconds":900}`), + camelValue: 900, + snakeValue: 0, + want: 900, + }, + } + + for _, tt := range tests { + if got := tunnelTTLFromPayload(tt.raw, tt.camelValue, tt.snakeValue); got != tt.want { + t.Fatalf("%s: tunnelTTLFromPayload = %d, want %d", tt.name, got, tt.want) + } + } +} diff --git a/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go b/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go new file mode 100644 index 000000000..f01e3867e --- /dev/null +++ b/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go @@ -0,0 +1,322 @@ +package server + +import ( + "encoding/json" + "strings" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +const websocketDefaultTunnelTTLSeconds = 3600 + +type websocketTunnelCreatePayload struct { + TargetURL string `json:"targetUrl"` + TargetUrl string `json:"target_url"` + Name string `json:"name"` + TTLSeconds uint32 `json:"ttlSeconds"` + TtlSeconds uint32 `json:"ttl_seconds"` + ProjectPathKey string `json:"projectPathKey"` + ProjectPathKeySnake string `json:"project_path_key"` +} + +func tunnelTTLFromPayload(raw json.RawMessage, camelValue uint32, snakeValue uint32) uint32 { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return websocketDefaultTunnelTTLSeconds + } + if _, ok := fields["ttlSeconds"]; ok { + return camelValue + } + if _, ok := fields["ttl_seconds"]; ok { + return snakeValue + } + return websocketDefaultTunnelTTLSeconds +} + +type websocketTunnelUpdatePayload struct { + ID string `json:"id"` + TunnelID string `json:"tunnelId"` + TunnelId string `json:"tunnel_id"` + Slug string `json:"slug"` + TargetURL string `json:"targetUrl"` + TargetUrl string `json:"target_url"` + Name string `json:"name"` + TTLSeconds uint32 `json:"ttlSeconds"` + TtlSeconds uint32 `json:"ttl_seconds"` + ProjectPathKey string `json:"projectPathKey"` + ProjectPathKeySnake string `json:"project_path_key"` +} + +type websocketTunnelClosePayload struct { + ID string `json:"id"` + TunnelID string `json:"tunnelId"` + TunnelId string `json:"tunnel_id"` + Slug string `json:"slug"` +} + +func (c *websocketConnection) handleTunnelList(req websocketRequest) { + _ = c.writeResponse(req.ID, map[string]any{ + "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), + }) +} + +func (c *websocketConnection) handleTunnelCreate(req websocketRequest) { + if !c.sm.WebTunnelsEnabled() { + _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") + return + } + + var body websocketTunnelCreatePayload + if err := decodeWebSocketPayload(req.Payload, &body); err != nil { + _ = c.writeError(req.ID, "invalid tunnel.create payload") + return + } + targetURL := strings.TrimSpace(body.TargetURL) + if targetURL == "" { + targetURL = strings.TrimSpace(body.TargetUrl) + } + ttlSeconds := tunnelTTLFromPayload(req.Payload, body.TTLSeconds, body.TtlSeconds) + projectPathKey := strings.TrimSpace(body.ProjectPathKey) + if projectPathKey == "" { + projectPathKey = strings.TrimSpace(body.ProjectPathKeySnake) + } + prepared, err := c.sm.PrepareTunnelCreate(&gatewayv1.TunnelControlRequest{ + Action: "create", + TargetUrl: targetURL, + Name: strings.TrimSpace(body.Name), + TtlSeconds: ttlSeconds, + ProjectPathKey: projectPathKey, + }, c.publicBaseURL()) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + + response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ + RequestId: req.ID, + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ + TunnelControl: prepared, + }, + }) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + if errResp := response.GetError(); errResp != nil { + _ = c.writeError(req.ID, errResp.GetMessage()) + return + } + controlResp := response.GetTunnelControlResp() + if controlResp == nil { + _ = c.writeError(req.ID, "unexpected agent response") + return + } + if controlResp.GetErrorMessage() != "" { + _ = c.writeError(req.ID, controlResp.GetErrorMessage()) + return + } + targetOverride := "" + if tunnel := controlResp.GetTunnel(); tunnel != nil { + targetOverride = tunnel.GetTargetUrl() + } + tunnel, err := c.sm.StorePreparedTunnel(prepared, targetOverride) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + _ = c.writeResponse(req.ID, map[string]any{ + "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), + "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), + }) +} + +func (c *websocketConnection) handleTunnelUpdate(req websocketRequest) { + if !c.sm.WebTunnelsEnabled() { + _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") + return + } + + var body websocketTunnelUpdatePayload + if err := decodeWebSocketPayload(req.Payload, &body); err != nil { + _ = c.writeError(req.ID, "invalid tunnel.update payload") + return + } + identifier := strings.TrimSpace(body.ID) + if identifier == "" { + identifier = strings.TrimSpace(body.TunnelID) + } + if identifier == "" { + identifier = strings.TrimSpace(body.TunnelId) + } + if identifier == "" { + identifier = strings.TrimSpace(body.Slug) + } + if identifier == "" { + _ = c.writeError(req.ID, "tunnel id is required") + return + } + targetURL := strings.TrimSpace(body.TargetURL) + if targetURL == "" { + targetURL = strings.TrimSpace(body.TargetUrl) + } + ttlSeconds := tunnelTTLFromPayload(req.Payload, body.TTLSeconds, body.TtlSeconds) + projectPathKey := strings.TrimSpace(body.ProjectPathKey) + if projectPathKey == "" { + projectPathKey = strings.TrimSpace(body.ProjectPathKeySnake) + } + prepared, err := c.sm.PrepareTunnelUpdate(&gatewayv1.TunnelControlRequest{ + Action: "update", + TunnelId: identifier, + TargetUrl: targetURL, + Name: strings.TrimSpace(body.Name), + TtlSeconds: ttlSeconds, + ProjectPathKey: projectPathKey, + }) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + + response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ + RequestId: req.ID, + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ + TunnelControl: prepared, + }, + }) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + if errResp := response.GetError(); errResp != nil { + _ = c.writeError(req.ID, errResp.GetMessage()) + return + } + controlResp := response.GetTunnelControlResp() + if controlResp == nil { + _ = c.writeError(req.ID, "unexpected agent response") + return + } + if controlResp.GetErrorMessage() != "" { + _ = c.writeError(req.ID, controlResp.GetErrorMessage()) + return + } + tunnel, err := c.sm.ApplyTunnelUpdate(controlResp.GetTunnel()) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + _ = c.writeResponse(req.ID, map[string]any{ + "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), + "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), + }) +} + +func (c *websocketConnection) handleTunnelClose(req websocketRequest) { + if !c.sm.WebTunnelsEnabled() { + _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") + return + } + + var body websocketTunnelClosePayload + if err := decodeWebSocketPayload(req.Payload, &body); err != nil { + _ = c.writeError(req.ID, "invalid tunnel.close payload") + return + } + identifier := strings.TrimSpace(body.ID) + if identifier == "" { + identifier = strings.TrimSpace(body.TunnelID) + } + if identifier == "" { + identifier = strings.TrimSpace(body.TunnelId) + } + if identifier == "" { + identifier = strings.TrimSpace(body.Slug) + } + if identifier == "" { + _ = c.writeError(req.ID, "tunnel id is required") + return + } + + tunnel, err := c.sm.CloseTunnel(identifier) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + + _ = c.sendToAgent(&gatewayv1.GatewayEnvelope{ + RequestId: "tunnel-close-" + tunnel.GetId(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ + TunnelControl: &gatewayv1.TunnelControlRequest{ + Action: "close", + TunnelId: tunnel.GetId(), + Slug: tunnel.GetSlug(), + }, + }, + }) + + _ = c.writeResponse(req.ID, map[string]any{ + "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), + "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), + }) +} + +func (c *websocketConnection) publicBaseURL() string { + return publicBaseURLFromHTTPRequest(c.conn.Request()) +} + +func websocketTunnelSummariesPayload( + summaries []*gatewayv1.TunnelSummary, + publicBaseURL string, +) []map[string]any { + payload := make([]map[string]any, 0, len(summaries)) + for _, summary := range summaries { + if item := websocketTunnelSummaryPayload(summary, publicBaseURL); item != nil { + payload = append(payload, item) + } + } + return payload +} + +func websocketTunnelSummaryPayload( + summary *gatewayv1.TunnelSummary, + publicBaseURL string, +) map[string]any { + if summary == nil { + return nil + } + publicURL := strings.TrimSpace(summary.GetPublicUrl()) + if publicURL == "" { + publicURL = buildPublicTunnelURL(publicBaseURL, summary.GetSlug()) + } + return map[string]any{ + "id": strings.TrimSpace(summary.GetId()), + "slug": strings.TrimSpace(summary.GetSlug()), + "name": strings.TrimSpace(summary.GetName()), + "targetUrl": strings.TrimSpace(summary.GetTargetUrl()), + "target_url": strings.TrimSpace(summary.GetTargetUrl()), + "publicUrl": publicURL, + "public_url": publicURL, + "createdAt": summary.GetCreatedAt(), + "created_at": summary.GetCreatedAt(), + "expiresAt": summary.GetExpiresAt(), + "expires_at": summary.GetExpiresAt(), + "activeConnections": summary.GetActiveConnections(), + "active_connections": summary.GetActiveConnections(), + "status": strings.TrimSpace(summary.GetStatus()), + "projectPathKey": strings.TrimSpace(summary.GetProjectPathKey()), + "project_path_key": strings.TrimSpace(summary.GetProjectPathKey()), + } +} + +func buildPublicTunnelURL(publicBaseURL string, slug string) string { + publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/") + slug = strings.TrimSpace(slug) + if publicBaseURL == "" || slug == "" { + return "" + } + return publicBaseURL + "/t/" + slug + "/" +} diff --git a/crates/agent-gateway/internal/session/agent_session.go b/crates/agent-gateway/internal/session/agent_session.go index 790e6ae90..5bee6705c 100644 --- a/crates/agent-gateway/internal/session/agent_session.go +++ b/crates/agent-gateway/internal/session/agent_session.go @@ -1,6 +1,7 @@ package session import ( + "context" "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" @@ -13,13 +14,37 @@ func NewAgentSession(auth AuthSnapshot) *AgentSession { SessionID: auth.SessionID, ConnectedAt: time.Now(), LastPing: time.Now(), - toAgent: make(chan *gatewayv1.GatewayEnvelope, 64), + toAgent: make(chan *OutboundEnvelope, 64), done: make(chan struct{}), streams: make(map[string]*agentStream), } } -func (s *AgentSession) Outbound() <-chan *gatewayv1.GatewayEnvelope { +type OutboundEnvelope struct { + *gatewayv1.GatewayEnvelope + + ctx context.Context + result chan error +} + +func (e *OutboundEnvelope) Context() context.Context { + if e == nil || e.ctx == nil { + return context.Background() + } + return e.ctx +} + +func (e *OutboundEnvelope) Ack(err error) { + if e == nil || e.result == nil { + return + } + select { + case e.result <- err: + default: + } +} + +func (s *AgentSession) Outbound() <-chan *OutboundEnvelope { return s.toAgent } @@ -41,6 +66,34 @@ func (s *AgentSession) Close() { } func (s *AgentSession) SendToAgent(env *gatewayv1.GatewayEnvelope) error { + return s.enqueueToAgent(context.Background(), env, nil) +} + +func (s *AgentSession) SendToAgentContext(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { + if ctx == nil { + ctx = context.Background() + } + result := make(chan error, 1) + if err := s.enqueueToAgent(ctx, env, result); err != nil { + return err + } + + select { + case err := <-result: + return err + case <-ctx.Done(): + s.Close() + return ctx.Err() + case <-s.done: + return ErrAgentOffline + } +} + +func (s *AgentSession) enqueueToAgent( + ctx context.Context, + env *gatewayv1.GatewayEnvelope, + result chan error, +) error { s.streamsMu.Lock() closed := s.closed s.streamsMu.Unlock() @@ -49,9 +102,15 @@ func (s *AgentSession) SendToAgent(env *gatewayv1.GatewayEnvelope) error { } select { + case <-ctx.Done(): + return ctx.Err() case <-s.done: return ErrAgentOffline - case s.toAgent <- env: + case s.toAgent <- &OutboundEnvelope{ + GatewayEnvelope: env, + ctx: ctx, + result: result, + }: return nil } } @@ -73,7 +132,7 @@ func (s *AgentSession) TrySendToAgent(env *gatewayv1.GatewayEnvelope) (bool, err select { case <-s.done: return false, ErrAgentOffline - case s.toAgent <- env: + case s.toAgent <- &OutboundEnvelope{GatewayEnvelope: env}: return true, nil default: return false, nil diff --git a/crates/agent-gateway/internal/session/manager.go b/crates/agent-gateway/internal/session/manager.go index c46bf6b1a..5a7479a9c 100644 --- a/crates/agent-gateway/internal/session/manager.go +++ b/crates/agent-gateway/internal/session/manager.go @@ -10,13 +10,22 @@ import ( var ErrAgentOffline = errors.New("agent offline") var ErrChatRunNotFound = errors.New("chat run not found") +var ErrTunnelNotFound = errors.New("tunnel not found") +var ErrTunnelExpired = errors.New("tunnel expired") +var ErrTunnelOverLimit = errors.New("tunnel connection limit exceeded") +var ErrTunnelLimitExceeded = errors.New("tunnel limit exceeded") const ( maxBufferedChatRunEvents = 50000 chatRunDoneRetention = time.Hour + chatRunStartRetention = 5 * time.Minute chatRunStaleRetention = 12 * time.Hour agentDisconnectedChatRunMessage = "Desktop agent disconnected. Please retry." + + chatRuntimeReadyTTL = 15 * time.Second + agentSessionHeartbeatTTL = 90 * time.Second + defaultRuntimeReadyState = "ready" ) type AuthSnapshot struct { @@ -29,6 +38,7 @@ type Manager struct { registry *sessionRegistry syncHub *syncHub chatStore *chatRunStore + tunnels *tunnelStore } type AgentSession struct { @@ -38,7 +48,7 @@ type AgentSession struct { ConnectedAt time.Time LastPing time.Time - toAgent chan *gatewayv1.GatewayEnvelope + toAgent chan *OutboundEnvelope done chan struct{} closeOnce sync.Once @@ -57,6 +67,7 @@ type agentStream struct { type ChatBroadcastEvent struct { RequestID string Event *gatewayv1.ChatEvent + Control *gatewayv1.ChatControlEvent Seq int64 Workdir string } @@ -68,6 +79,9 @@ type ChatRunSnapshot struct { Workdir string FirstSeq int64 LatestSeq int64 + RunEpoch int64 + State string + ErrorCode string Done bool } @@ -77,14 +91,30 @@ type ActiveChatRunSummary struct { UpdatedAt int64 } +const ( + ChatRunStateQueued = "queued" + ChatRunStateDelivered = "delivered" + ChatRunStateClaimed = "claimed" + ChatRunStateStarting = "starting" + ChatRunStateRunning = "running" + ChatRunStateCompleted = "completed" + ChatRunStateFailed = "failed" + ChatRunStateCancelled = "cancelled" +) + type chatRun struct { requestID string conversationID string clientRequestID string workdir string sessionEpoch uint64 + runEpoch int64 events []*ChatBroadcastEvent nextSeq int64 + state string + errorCode string + accepted bool + started bool done bool updatedAt time.Time expiresAt time.Time @@ -104,12 +134,19 @@ type chatRunSubscriber struct { } type Status struct { - Online bool `json:"online"` - AgentID string `json:"agent_id"` - AgentVersion string `json:"agent_version"` - SessionID string `json:"session_id,omitempty"` - ConnectedSince int64 `json:"connected_since"` - LastHeartbeat int64 `json:"last_heartbeat"` + Online bool `json:"online"` + AgentReady bool `json:"agent_ready"` + ChatRuntimeReady bool `json:"chat_runtime_ready"` + AgentID string `json:"agent_id"` + AgentVersion string `json:"agent_version"` + SessionID string `json:"session_id,omitempty"` + ConnectedSince int64 `json:"connected_since"` + LastHeartbeat int64 `json:"last_heartbeat"` + RuntimeState string `json:"runtime_state,omitempty"` + RuntimeLastHeartbeat int64 `json:"runtime_last_heartbeat,omitempty"` + RuntimeWorkerID string `json:"runtime_worker_id,omitempty"` + RuntimeVisible bool `json:"runtime_visible,omitempty"` + RuntimeActiveRunCount uint32 `json:"runtime_active_run_count,omitempty"` } func NewManager() *Manager { @@ -117,5 +154,6 @@ func NewManager() *Manager { registry: newSessionRegistry(), syncHub: newSyncHub(), chatStore: newChatRunStore(), + tunnels: newTunnelStore(), } } diff --git a/crates/agent-gateway/internal/session/manager_chat_runs.go b/crates/agent-gateway/internal/session/manager_chat_runs.go index ef8eb3e35..1cf07a609 100644 --- a/crates/agent-gateway/internal/session/manager_chat_runs.go +++ b/crates/agent-gateway/internal/session/manager_chat_runs.go @@ -42,6 +42,25 @@ func (m *Manager) StartChatRunWithClientRequest( conversationID string, clientRequestID string, workdirInput ...string, +) (ChatRunSnapshot, bool, error) { + return m.startChatRunWithClientRequest(requestID, conversationID, clientRequestID, true, workdirInput...) +} + +func (m *Manager) StartPendingChatRunWithClientRequest( + requestID string, + conversationID string, + clientRequestID string, + workdirInput ...string, +) (ChatRunSnapshot, bool, error) { + return m.startChatRunWithClientRequest(requestID, conversationID, clientRequestID, false, workdirInput...) +} + +func (m *Manager) startChatRunWithClientRequest( + requestID string, + conversationID string, + clientRequestID string, + started bool, + workdirInput ...string, ) (ChatRunSnapshot, bool, error) { requestID = strings.TrimSpace(requestID) if requestID == "" { @@ -68,6 +87,9 @@ func (m *Manager) StartChatRunWithClientRequest( if workdir != "" && existing.workdir == "" { existing.workdir = workdir } + if started && existing.state != ChatRunStateRunning { + existing.applyState(ChatRunStateRunning) + } return existing.snapshot(), false, nil } m.releaseCompletedChatRunLocked(existingRequestID, existing) @@ -80,15 +102,23 @@ func (m *Manager) StartChatRunWithClientRequest( m.removeChatRunLocked(requestID, existing) } + m.chatStore.nextChatRunEpoch += 1 + initialState := ChatRunStateQueued + if started { + initialState = ChatRunStateRunning + } run := &chatRun{ requestID: requestID, conversationID: conversationID, clientRequestID: clientRequestID, workdir: workdir, sessionEpoch: sessionEpoch, + runEpoch: m.chatStore.nextChatRunEpoch, + state: initialState, updatedAt: now, subscribers: make(map[int]*chatRunSubscriber), } + run.applyState(initialState) m.chatStore.chatRuns[requestID] = run if conversationID != "" { m.chatStore.chatRunByConversation[conversationID] = requestID @@ -149,7 +179,7 @@ func (m *Manager) ActiveChatRunSummaries() []ActiveChatRunSummary { seen := make(map[string]int, len(m.chatStore.chatRuns)+len(m.chatStore.historyActiveRuns)) summaries := make([]ActiveChatRunSummary, 0, len(m.chatStore.chatRuns)+len(m.chatStore.historyActiveRuns)) for _, run := range m.chatStore.chatRuns { - if run == nil || run.done { + if run == nil || run.done || normalizeChatRunState(run.state) != ChatRunStateRunning { continue } conversationID := strings.TrimSpace(run.conversationID) @@ -232,7 +262,7 @@ func (m *Manager) failOpenChatRunsForSessionEpoch(sessionEpoch uint64, message s now := time.Now() type broadcastTarget struct { - event *ChatBroadcastEvent + events []*ChatBroadcastEvent subscribers []*chatRunSubscriber } targets := make([]broadcastTarget, 0) @@ -247,7 +277,8 @@ func (m *Manager) failOpenChatRunsForSessionEpoch(sessionEpoch uint64, message s run.nextSeq += 1 run.updatedAt = now - run.done = true + run.applyState(ChatRunStateFailed) + run.errorCode = "agent_disconnected" run.expiresAt = now.Add(chatRunDoneRetention) chatEvent := &gatewayv1.ChatEvent{ @@ -272,7 +303,7 @@ func (m *Manager) failOpenChatRunsForSessionEpoch(sessionEpoch uint64, message s subscribers = append(subscribers, subscriber) } targets = append(targets, broadcastTarget{ - event: broadcast, + events: []*ChatBroadcastEvent{broadcast}, subscribers: subscribers, }) } @@ -283,18 +314,143 @@ func (m *Manager) failOpenChatRunsForSessionEpoch(sessionEpoch uint64, message s for _, target := range targets { for _, subscriber := range target.subscribers { - select { - case <-subscriber.done: - case subscriber.ch <- cloneChatBroadcastEvent(target.event): + for _, event := range target.events { + select { + case <-subscriber.done: + case subscriber.ch <- cloneChatBroadcastEvent(event): + } } } for _, ch := range globalSubscribers { - select { - case ch <- cloneChatBroadcastEvent(target.event): - default: + for _, event := range target.events { + select { + case ch <- cloneChatBroadcastEvent(event): + default: + } + } + } + } +} + +func (m *Manager) FailStartingChatRun(requestID string, message string) bool { + failed, sessionEpoch := m.failChatRunIf( + requestID, + message, + "Desktop backend did not accept the remote chat request. Please retry.", + func(run *chatRun) bool { + if run == nil || run.done { + return false } + state := normalizeChatRunState(run.state) + return state == ChatRunStateQueued + }, + ) + if failed { + m.ClearSessionForEpoch(sessionEpoch) + } + return failed +} + +func (m *Manager) FailUnstartedChatRun(requestID string, message string) bool { + failed, _ := m.failChatRunIf( + requestID, + message, + "Desktop app accepted the remote chat request but did not start it. Please retry.", + func(run *chatRun) bool { + if run == nil || run.done { + return false + } + state := normalizeChatRunState(run.state) + return state != ChatRunStateQueued && + state != ChatRunStateRunning && + !isTerminalChatRunState(state) + }, + ) + return failed +} + +func (m *Manager) failChatRunIf( + requestID string, + message string, + defaultMessage string, + shouldFail func(*chatRun) bool, +) (bool, uint64) { + requestID = strings.TrimSpace(requestID) + message = strings.TrimSpace(message) + if requestID == "" { + return false, 0 + } + if message == "" { + message = defaultMessage + } + + data, err := json.Marshal(map[string]string{"message": message}) + if err != nil { + fallback, marshalErr := json.Marshal(map[string]string{"message": defaultMessage}) + if marshalErr != nil { + fallback = []byte(`{"message":"Remote chat request failed. Please retry."}`) + } + data = fallback + } + + now := time.Now() + var broadcast *ChatBroadcastEvent + var runSubscribers []*chatRunSubscriber + var subscribers []chan *ChatBroadcastEvent + + m.chatStore.chatMu.Lock() + m.pruneExpiredChatRunsLocked(now) + run := m.chatStore.chatRuns[requestID] + if shouldFail == nil || !shouldFail(run) { + m.chatStore.chatMu.Unlock() + return false, 0 + } + sessionEpoch := run.sessionEpoch + + run.nextSeq += 1 + run.updatedAt = now + run.applyState(ChatRunStateFailed) + run.errorCode = "desktop_runtime_unavailable" + run.expiresAt = now.Add(chatRunDoneRetention) + chatEvent := &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_ERROR, + ConversationId: strings.TrimSpace(run.conversationID), + Data: string(data), + } + broadcast = &ChatBroadcastEvent{ + RequestID: requestID, + Event: chatEvent, + Seq: run.nextSeq, + Workdir: strings.TrimSpace(run.workdir), + } + run.events = append(run.events, cloneChatBroadcastEvent(broadcast)) + if len(run.events) > maxBufferedChatRunEvents { + copy(run.events, run.events[len(run.events)-maxBufferedChatRunEvents:]) + run.events = run.events[:maxBufferedChatRunEvents] + } + runSubscribers = make([]*chatRunSubscriber, 0, len(run.subscribers)) + for _, subscriber := range run.subscribers { + runSubscribers = append(runSubscribers, subscriber) + } + subscribers = make([]chan *ChatBroadcastEvent, 0, len(m.chatStore.chatSubscribers)) + for _, ch := range m.chatStore.chatSubscribers { + subscribers = append(subscribers, ch) + } + m.chatStore.chatMu.Unlock() + + for _, subscriber := range runSubscribers { + select { + case <-subscriber.done: + case subscriber.ch <- cloneChatBroadcastEvent(broadcast): } } + for _, ch := range subscribers { + select { + case ch <- cloneChatBroadcastEvent(broadcast): + default: + } + } + return true, sessionEpoch } func (m *Manager) SubscribeChatRun( @@ -381,6 +537,14 @@ func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEven conversationID := strings.TrimSpace(event.GetConversationId()) now := time.Now() sessionEpoch := m.currentSessionEpoch() + if isChatAcceptedControlEvent(event) { + m.markChatRunStateSilent(requestID, conversationID, ChatRunStateDelivered, now) + return + } + if isChatStartedControlEvent(event) { + m.markChatRunStateSilent(requestID, conversationID, ChatRunStateRunning, now) + return + } m.chatStore.chatMu.Lock() m.pruneExpiredChatRunsLocked(now) @@ -391,21 +555,27 @@ func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEven var runSubscribers []*chatRunSubscriber run := m.chatStore.chatRuns[requestID] if run == nil && requestID != "" { + m.chatStore.nextChatRunEpoch += 1 run = &chatRun{ requestID: requestID, conversationID: conversationID, sessionEpoch: sessionEpoch, + runEpoch: m.chatStore.nextChatRunEpoch, + state: ChatRunStateQueued, updatedAt: now, subscribers: make(map[int]*chatRunSubscriber), } + run.applyState(ChatRunStateQueued) m.chatStore.chatRuns[requestID] = run if conversationID != "" { m.chatStore.chatRunByConversation[conversationID] = requestID } } if run != nil { - run.nextSeq += 1 - run.updatedAt = now + if run.done { + m.chatStore.chatMu.Unlock() + return + } if conversationID != "" { if run.conversationID != "" && run.conversationID != conversationID { if m.chatStore.chatRunByConversation[run.conversationID] == requestID { @@ -420,6 +590,11 @@ func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEven } } } + if !run.done && normalizeChatRunState(run.state) != ChatRunStateRunning && !isTerminalChatEvent(event) { + run.applyState(ChatRunStateRunning) + } + run.nextSeq += 1 + run.updatedAt = now broadcast.Seq = run.nextSeq broadcast.Workdir = strings.TrimSpace(run.workdir) run.events = append(run.events, cloneChatBroadcastEvent(broadcast)) @@ -428,7 +603,14 @@ func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEven run.events = run.events[:maxBufferedChatRunEvents] } if isTerminalChatEvent(event) { - run.done = true + if event.GetType() == gatewayv1.ChatEvent_DONE { + run.applyState(ChatRunStateCompleted) + } else { + run.applyState(ChatRunStateFailed) + if run.errorCode == "" { + run.errorCode = "desktop_error" + } + } run.expiresAt = now.Add(chatRunDoneRetention) } runSubscribers = make([]*chatRunSubscriber, 0, len(run.subscribers)) @@ -456,6 +638,139 @@ func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEven } } +func (m *Manager) broadcastChatControl(requestID string, control *gatewayv1.ChatControlEvent) { + if control == nil { + return + } + requestID = strings.TrimSpace(requestID) + if requestID == "" { + requestID = strings.TrimSpace(control.GetRequestId()) + } + conversationID := strings.TrimSpace(control.GetConversationId()) + controlType := strings.TrimSpace(control.GetType()) + state := normalizeChatRunState(control.GetState()) + if state == "" { + state = chatRunStateForControlType(controlType) + } + errorCode := strings.TrimSpace(control.GetErrorCode()) + message := strings.TrimSpace(control.GetMessage()) + m.markChatRunControl(requestID, conversationID, controlType, state, errorCode, message, time.Now()) +} + +func (m *Manager) markChatRunStateSilent( + requestID string, + conversationID string, + state string, + now time.Time, +) { + requestID = strings.TrimSpace(requestID) + conversationID = strings.TrimSpace(conversationID) + state = normalizeChatRunState(state) + if requestID == "" || state == "" { + return + } + m.chatStore.chatMu.Lock() + defer m.chatStore.chatMu.Unlock() + m.pruneExpiredChatRunsLocked(now) + run := m.chatStore.chatRuns[requestID] + if run == nil || run.done { + return + } + if conversationID != "" { + if run.conversationID != "" && run.conversationID != conversationID { + if m.chatStore.chatRunByConversation[run.conversationID] == requestID { + delete(m.chatStore.chatRunByConversation, run.conversationID) + } + } + run.conversationID = conversationID + m.chatStore.chatRunByConversation[conversationID] = requestID + } + run.applyState(state) + run.updatedAt = now + if isTerminalChatRunState(state) { + run.expiresAt = now.Add(chatRunDoneRetention) + } +} + +func (m *Manager) markChatRunControl( + requestID string, + conversationID string, + controlType string, + state string, + errorCode string, + message string, + now time.Time, +) { + requestID = strings.TrimSpace(requestID) + conversationID = strings.TrimSpace(conversationID) + if requestID == "" { + return + } + + state = normalizeChatRunState(state) + controlType = strings.TrimSpace(controlType) + if controlType == "" { + controlType = chatControlTypeForState(state) + } + + m.chatStore.chatMu.Lock() + m.pruneExpiredChatRunsLocked(now) + run := m.chatStore.chatRuns[requestID] + if run == nil { + m.chatStore.nextChatRunEpoch += 1 + run = &chatRun{ + requestID: requestID, + conversationID: conversationID, + sessionEpoch: m.currentSessionEpoch(), + runEpoch: m.chatStore.nextChatRunEpoch, + state: ChatRunStateQueued, + updatedAt: now, + subscribers: make(map[int]*chatRunSubscriber), + } + run.applyState(ChatRunStateQueued) + m.chatStore.chatRuns[requestID] = run + } + if run.done { + m.chatStore.chatMu.Unlock() + return + } + if conversationID != "" { + if run.conversationID != "" && run.conversationID != conversationID { + if m.chatStore.chatRunByConversation[run.conversationID] == requestID { + delete(m.chatStore.chatRunByConversation, run.conversationID) + } + } + run.conversationID = conversationID + m.chatStore.chatRunByConversation[conversationID] = requestID + } + broadcast := m.appendChatControlLocked(run, controlType, errorCode, message, now) + runSubscribers := make([]*chatRunSubscriber, 0, len(run.subscribers)) + for _, subscriber := range run.subscribers { + runSubscribers = append(runSubscribers, subscriber) + } + subscribers := make([]chan *ChatBroadcastEvent, 0, len(m.chatStore.chatSubscribers)) + for _, ch := range m.chatStore.chatSubscribers { + subscribers = append(subscribers, ch) + } + m.chatStore.chatMu.Unlock() + + if broadcast == nil { + return + } + for _, subscriber := range runSubscribers { + select { + case <-subscriber.done: + case subscriber.ch <- cloneChatBroadcastEvent(broadcast): + } + } + for _, ch := range subscribers { + select { + case ch <- cloneChatBroadcastEvent(broadcast): + default: + } + } +} + func (m *Manager) DispatchFromAgent(env *gatewayv1.AgentEnvelope) { m.dispatchFromAgent(nil, env) } @@ -472,10 +787,19 @@ func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.Agent return } + if runtimeStatus := env.GetRuntimeStatus(); runtimeStatus != nil { + m.UpdateRuntimeStatus(session, runtimeStatus) + return + } + if chatEvent := env.GetChatEvent(); chatEvent != nil { m.broadcastChatEvent(env.GetRequestId(), chatEvent) } + if chatControl := env.GetChatControl(); chatControl != nil { + m.broadcastChatControl(env.GetRequestId(), chatControl) + } + if historySync := env.GetHistorySync(); historySync != nil { m.broadcastHistorySync(historySync) return @@ -491,6 +815,16 @@ func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.Agent return } + if tunnelFrame := env.GetTunnelFrame(); tunnelFrame != nil { + m.dispatchTunnelFrame(tunnelFrame) + return + } + + if tunnelControl := env.GetTunnelControl(); tunnelControl != nil { + m.handleAgentTunnelControl(session, env.GetRequestId(), tunnelControl) + return + } + session.dispatch(env) } @@ -499,6 +833,7 @@ func (r *chatRun) snapshot() ChatRunSnapshot { if len(r.events) > 0 { firstSeq = r.events[0].Seq } + state := normalizeChatRunState(r.state) return ChatRunSnapshot{ RequestID: r.requestID, ConversationID: r.conversationID, @@ -506,10 +841,27 @@ func (r *chatRun) snapshot() ChatRunSnapshot { Workdir: r.workdir, FirstSeq: firstSeq, LatestSeq: r.nextSeq, + RunEpoch: r.runEpoch, + State: state, + ErrorCode: strings.TrimSpace(r.errorCode), Done: r.done, } } +func (r *chatRun) applyState(state string) { + state = normalizeChatRunState(state) + if state == "" { + state = ChatRunStateQueued + } + r.state = state + r.accepted = state != ChatRunStateQueued + r.started = state == ChatRunStateRunning || state == ChatRunStateCompleted + r.done = isTerminalChatRunState(state) + if state != ChatRunStateFailed { + r.errorCode = "" + } +} + func (s *chatRunSubscriber) close() { s.closeOnce.Do(func() { close(s.done) @@ -528,6 +880,10 @@ func (m *Manager) pruneExpiredChatRunsLocked(now time.Time) { } continue } + if normalizeChatRunState(run.state) != ChatRunStateRunning && !run.updatedAt.IsZero() && now.Sub(run.updatedAt) > chatRunStartRetention { + m.removeChatRunLocked(requestID, run) + continue + } if !run.updatedAt.IsZero() && now.Sub(run.updatedAt) > chatRunStaleRetention { m.removeChatRunLocked(requestID, run) } @@ -564,14 +920,182 @@ func cloneChatBroadcastEvent(event *ChatBroadcastEvent) *ChatBroadcastEvent { return &ChatBroadcastEvent{ RequestID: event.RequestID, Event: event.Event, + Control: event.Control, Seq: event.Seq, Workdir: event.Workdir, } } +func normalizeChatRunState(state string) string { + switch strings.TrimSpace(state) { + case ChatRunStateQueued: + return ChatRunStateQueued + case ChatRunStateDelivered: + return ChatRunStateDelivered + case ChatRunStateClaimed: + return ChatRunStateClaimed + case ChatRunStateStarting: + return ChatRunStateStarting + case ChatRunStateRunning: + return ChatRunStateRunning + case ChatRunStateCompleted: + return ChatRunStateCompleted + case ChatRunStateFailed: + return ChatRunStateFailed + case ChatRunStateCancelled: + return ChatRunStateCancelled + default: + return "" + } +} + +func isTerminalChatRunState(state string) bool { + switch normalizeChatRunState(state) { + case ChatRunStateCompleted, ChatRunStateFailed, ChatRunStateCancelled: + return true + default: + return false + } +} + +func ChatRunStateIsActive(state string) bool { + switch normalizeChatRunState(state) { + case ChatRunStateQueued, ChatRunStateDelivered, ChatRunStateClaimed, ChatRunStateStarting, ChatRunStateRunning: + return true + default: + return false + } +} + +func chatRunStateForControlType(controlType string) string { + switch strings.TrimSpace(controlType) { + case "accepted": + return ChatRunStateQueued + case "delivered": + return ChatRunStateDelivered + case "claimed": + return ChatRunStateClaimed + case "starting": + return ChatRunStateStarting + case "started": + return ChatRunStateRunning + case "completed": + return ChatRunStateCompleted + case "failed": + return ChatRunStateFailed + case "cancelled": + return ChatRunStateCancelled + default: + return "" + } +} + +func chatControlTypeForState(state string) string { + switch normalizeChatRunState(state) { + case ChatRunStateQueued: + return "accepted" + case ChatRunStateDelivered: + return "delivered" + case ChatRunStateClaimed: + return "claimed" + case ChatRunStateStarting: + return "starting" + case ChatRunStateRunning: + return "started" + case ChatRunStateCompleted: + return "completed" + case ChatRunStateFailed: + return "failed" + case ChatRunStateCancelled: + return "cancelled" + default: + return "progress" + } +} + +func (m *Manager) appendChatControlLocked( + run *chatRun, + controlType string, + errorCode string, + message string, + now time.Time, +) *ChatBroadcastEvent { + if run == nil { + return nil + } + controlType = strings.TrimSpace(controlType) + state := chatRunStateForControlType(controlType) + if state == "" { + state = normalizeChatRunState(run.state) + } + if state == "" { + state = ChatRunStateQueued + } + run.applyState(state) + if errorCode = strings.TrimSpace(errorCode); errorCode != "" { + run.errorCode = errorCode + } + run.updatedAt = now + if isTerminalChatRunState(state) { + run.expiresAt = now.Add(chatRunDoneRetention) + } + run.nextSeq += 1 + seq := run.nextSeq + if controlType == "" { + controlType = chatControlTypeForState(state) + } + control := &gatewayv1.ChatControlEvent{ + RequestId: strings.TrimSpace(run.requestID), + ClientRequestId: strings.TrimSpace(run.clientRequestID), + ConversationId: strings.TrimSpace(run.conversationID), + RunEpoch: run.runEpoch, + Type: controlType, + State: normalizeChatRunState(run.state), + ErrorCode: strings.TrimSpace(run.errorCode), + Message: strings.TrimSpace(message), + Seq: seq, + } + broadcast := &ChatBroadcastEvent{ + RequestID: strings.TrimSpace(run.requestID), + Control: control, + Seq: seq, + Workdir: strings.TrimSpace(run.workdir), + } + run.events = append(run.events, cloneChatBroadcastEvent(broadcast)) + if len(run.events) > maxBufferedChatRunEvents { + copy(run.events, run.events[len(run.events)-maxBufferedChatRunEvents:]) + run.events = run.events[:maxBufferedChatRunEvents] + } + return broadcast +} + func isTerminalChatEvent(event *gatewayv1.ChatEvent) bool { if event == nil { return false } return event.GetType() == gatewayv1.ChatEvent_DONE || event.GetType() == gatewayv1.ChatEvent_ERROR } + +func isChatStartedControlEvent(event *gatewayv1.ChatEvent) bool { + return chatControlEventType(event) == "started" +} + +func isChatAcceptedControlEvent(event *gatewayv1.ChatEvent) bool { + return chatControlEventType(event) == "accepted" +} + +func chatControlEventType(event *gatewayv1.ChatEvent) string { + if event == nil || event.GetType() != gatewayv1.ChatEvent_TOKEN { + return "" + } + raw := strings.TrimSpace(event.GetData()) + if raw == "" { + return "" + } + var decoded map[string]any + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + return "" + } + value, _ := decoded["type"].(string) + return strings.TrimSpace(value) +} diff --git a/crates/agent-gateway/internal/session/manager_registry.go b/crates/agent-gateway/internal/session/manager_registry.go index 09609a19c..c31f46961 100644 --- a/crates/agent-gateway/internal/session/manager_registry.go +++ b/crates/agent-gateway/internal/session/manager_registry.go @@ -1,6 +1,8 @@ package session import ( + "context" + "strings" "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" @@ -40,6 +42,7 @@ func (m *Manager) SetSession(s *AgentSession) { } if previous != s { m.registry.sessionEpoch += 1 + clearRuntimeStatusLocked(m.registry) } sessionChanged := previous != s m.registry.session = s @@ -62,6 +65,7 @@ func (m *Manager) ClearSession(session *AgentSession) { } clearedEpoch := m.registry.sessionEpoch m.registry.session = nil + clearRuntimeStatusLocked(m.registry) m.registry.mu.Unlock() if session == nil { @@ -73,10 +77,54 @@ func (m *Manager) ClearSession(session *AgentSession) { m.failOpenChatRunsForSessionEpoch(clearedEpoch, agentDisconnectedChatRunMessage) } +func (m *Manager) ClearSessionIfHeartbeatStale(session *AgentSession, timeout time.Duration) bool { + if session == nil || timeout <= 0 { + return false + } + + now := time.Now() + m.registry.mu.Lock() + if m.registry.session != session { + m.registry.mu.Unlock() + return false + } + if lastPing := m.registry.session.LastPing; !lastPing.IsZero() && now.Sub(lastPing) <= timeout { + m.registry.mu.Unlock() + return false + } + clearedEpoch := m.registry.sessionEpoch + m.registry.session = nil + clearRuntimeStatusLocked(m.registry) + m.registry.mu.Unlock() + + session.Close() + m.clearTerminalSessionSnapshot() + m.failOpenChatRunsForSessionEpoch(clearedEpoch, agentDisconnectedChatRunMessage) + return true +} + +func (m *Manager) ClearSessionForEpoch(sessionEpoch uint64) bool { + m.registry.mu.Lock() + session := m.registry.session + if session == nil || m.registry.sessionEpoch != sessionEpoch { + m.registry.mu.Unlock() + return false + } + m.registry.session = nil + clearRuntimeStatusLocked(m.registry) + m.registry.mu.Unlock() + + session.Close() + m.clearTerminalSessionSnapshot() + m.failOpenChatRunsForSessionEpoch(sessionEpoch, agentDisconnectedChatRunMessage) + return true +} + func (m *Manager) Status() Status { m.registry.mu.RLock() defer m.registry.mu.RUnlock() + now := time.Now() status := Status{} if m.registry.authValid { status.AgentID = m.registry.lastAuth.AgentID @@ -87,14 +135,52 @@ func (m *Manager) Status() Status { return status } status.Online = true + status.AgentReady = true status.AgentID = m.registry.session.AgentID status.AgentVersion = m.registry.session.AgentVersion status.SessionID = m.registry.session.SessionID status.ConnectedSince = m.registry.session.ConnectedAt.Unix() status.LastHeartbeat = m.registry.session.LastPing.Unix() + status.RuntimeState = m.registry.runtimeState + status.RuntimeWorkerID = m.registry.runtimeWorkerID + status.RuntimeVisible = m.registry.runtimeVisible + status.RuntimeActiveRunCount = m.registry.runtimeActiveRunCount + if !m.registry.runtimeLastHeartbeat.IsZero() { + status.RuntimeLastHeartbeat = m.registry.runtimeLastHeartbeat.Unix() + } + status.ChatRuntimeReady = runtimeReadyLocked(m.registry, now) return status } +func (m *Manager) ChatRuntimeReady() bool { + m.registry.mu.RLock() + defer m.registry.mu.RUnlock() + return runtimeReadyLocked(m.registry, time.Now()) +} + +func (m *Manager) UpdateRuntimeStatus( + session *AgentSession, + event *gatewayv1.RuntimeStatusEvent, +) { + if event == nil { + return + } + workerID := strings.TrimSpace(event.GetWorkerId()) + state := normalizeRuntimeState(event.GetState()) + now := time.Now() + + m.registry.mu.Lock() + defer m.registry.mu.Unlock() + if m.registry.session == nil || (session != nil && m.registry.session != session) { + return + } + m.registry.runtimeState = state + m.registry.runtimeWorkerID = workerID + m.registry.runtimeLastHeartbeat = now + m.registry.runtimeVisible = event.GetVisible() + m.registry.runtimeActiveRunCount = event.GetActiveRunCount() +} + func (m *Manager) TouchHeartbeat(session *AgentSession) { m.registry.mu.Lock() defer m.registry.mu.Unlock() @@ -103,6 +189,42 @@ func (m *Manager) TouchHeartbeat(session *AgentSession) { } } +func clearRuntimeStatusLocked(registry *sessionRegistry) { + registry.runtimeState = "" + registry.runtimeWorkerID = "" + registry.runtimeLastHeartbeat = time.Time{} + registry.runtimeVisible = false + registry.runtimeActiveRunCount = 0 +} + +func runtimeReadyLocked(registry *sessionRegistry, now time.Time) bool { + if registry == nil || registry.session == nil { + return false + } + if registry.session.LastPing.IsZero() || now.Sub(registry.session.LastPing) > agentSessionHeartbeatTTL { + return false + } + if registry.runtimeLastHeartbeat.IsZero() || + now.Sub(registry.runtimeLastHeartbeat) > chatRuntimeReadyTTL { + return false + } + switch normalizeRuntimeState(registry.runtimeState) { + case "ready", "draining", "busy": + return true + default: + return false + } +} + +func normalizeRuntimeState(state string) string { + switch strings.TrimSpace(state) { + case "ready", "draining", "busy", "suspended": + return strings.TrimSpace(state) + default: + return defaultRuntimeReadyState + } +} + func (m *Manager) SendToAgent(env *gatewayv1.GatewayEnvelope) error { m.registry.mu.RLock() session := m.registry.session @@ -111,7 +233,29 @@ func (m *Manager) SendToAgent(env *gatewayv1.GatewayEnvelope) error { return ErrAgentOffline } - return session.SendToAgent(env) + err := session.SendToAgent(env) + m.clearSessionAfterSendError(session, err) + return err +} + +func (m *Manager) SendToAgentContext(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { + m.registry.mu.RLock() + session := m.registry.session + m.registry.mu.RUnlock() + if session == nil { + return ErrAgentOffline + } + + err := session.SendToAgentContext(ctx, env) + m.clearSessionAfterSendError(session, err) + return err +} + +func (m *Manager) clearSessionAfterSendError(session *AgentSession, err error) { + if err == nil || session == nil { + return + } + m.ClearSession(session) } func (m *Manager) currentSessionEpoch() uint64 { diff --git a/crates/agent-gateway/internal/session/manager_state.go b/crates/agent-gateway/internal/session/manager_state.go index a32a4528b..55c780bbe 100644 --- a/crates/agent-gateway/internal/session/manager_state.go +++ b/crates/agent-gateway/internal/session/manager_state.go @@ -2,6 +2,7 @@ package session import ( "sync" + "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) @@ -12,6 +13,12 @@ type sessionRegistry struct { sessionEpoch uint64 lastAuth AuthSnapshot authValid bool + + runtimeState string + runtimeWorkerID string + runtimeLastHeartbeat time.Time + runtimeVisible bool + runtimeActiveRunCount uint32 } func newSessionRegistry() *sessionRegistry { @@ -49,6 +56,7 @@ type chatRunStore struct { nextChatSubID int chatSubscribers map[int]chan *ChatBroadcastEvent nextChatRunSubID int + nextChatRunEpoch int64 chatRuns map[string]*chatRun chatRunByConversation map[string]string chatRunByClientRequest map[string]string diff --git a/crates/agent-gateway/internal/session/manager_tunnel.go b/crates/agent-gateway/internal/session/manager_tunnel.go new file mode 100644 index 000000000..aaf2c1712 --- /dev/null +++ b/crates/agent-gateway/internal/session/manager_tunnel.go @@ -0,0 +1,843 @@ +package session + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" + "sync" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +const ( + MaxTunnelsPerAgent = 5 + MaxTunnelConnections = 20 + defaultTunnelTTLSeconds = 3600 + tunnelSlugEntropyBytes = 24 + tunnelStreamChannelDepth = 256 + tunnelAgentSendTimeout = 10 * time.Second +) + +type tunnelStore struct { + mu sync.Mutex + tunnelsByID map[string]*tunnelRecord + tunnelIDBySlug map[string]string + streams map[string]*tunnelStream +} + +type tunnelRecord struct { + id string + slug string + name string + targetURL string + publicURL string + projectPathKey string + createdAt time.Time + expiresAt time.Time + activeConnections int + closed bool +} + +type tunnelStream struct { + streamID string + tunnelID string + ch chan *gatewayv1.TunnelFrame + done chan struct{} + once sync.Once +} + +type TunnelStreamLease struct { + manager *Manager + stream *tunnelStream + tunnel *gatewayv1.TunnelSummary + once sync.Once +} + +func newTunnelStore() *tunnelStore { + return &tunnelStore{ + tunnelsByID: make(map[string]*tunnelRecord), + tunnelIDBySlug: make(map[string]string), + streams: make(map[string]*tunnelStream), + } +} + +func (l *TunnelStreamLease) Tunnel() *gatewayv1.TunnelSummary { + if l == nil || l.tunnel == nil { + return nil + } + return cloneTunnelSummary(l.tunnel) +} + +func (l *TunnelStreamLease) TunnelID() string { + if l == nil || l.stream == nil { + return "" + } + return l.stream.tunnelID +} + +func (l *TunnelStreamLease) StreamID() string { + if l == nil || l.stream == nil { + return "" + } + return l.stream.streamID +} + +func (l *TunnelStreamLease) Frames() <-chan *gatewayv1.TunnelFrame { + if l == nil || l.stream == nil { + return nil + } + return l.stream.ch +} + +func (l *TunnelStreamLease) Done() <-chan struct{} { + if l == nil || l.stream == nil { + return nil + } + return l.stream.done +} + +func (l *TunnelStreamLease) Release() { + if l == nil { + return + } + l.once.Do(func() { + l.manager.releaseTunnelStream(l.stream) + }) +} + +func (s *tunnelStream) close() { + if s == nil { + return + } + s.once.Do(func() { + close(s.done) + }) +} + +func (s *tunnelStream) send(frame *gatewayv1.TunnelFrame) bool { + select { + case <-s.done: + return false + case s.ch <- frame: + return true + } +} + +func (m *Manager) WebTunnelsEnabled() bool { + m.syncHub.settingsSnapshotMu.RLock() + defer m.syncHub.settingsSnapshotMu.RUnlock() + + remote, ok := m.syncHub.settingsSnapshot["remote"].(map[string]any) + if !ok { + return false + } + enabled, ok := remote["enableWebTunnels"].(bool) + return ok && enabled +} + +func (m *Manager) ListTunnels() []*gatewayv1.TunnelSummary { + now := time.Now() + online := m.IsOnline() + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + summaries := make([]*gatewayv1.TunnelSummary, 0, len(m.tunnels.tunnelsByID)) + for _, record := range m.tunnels.tunnelsByID { + if record == nil || record.closed { + continue + } + summaries = append(summaries, tunnelSummaryLocked(record, now, online)) + } + sortTunnelSummaries(summaries) + return summaries +} + +func (m *Manager) PrepareTunnelCreate( + input *gatewayv1.TunnelControlRequest, + publicBaseURL string, +) (*gatewayv1.TunnelControlRequest, error) { + if input == nil { + return nil, errors.New("tunnel create input is required") + } + ttlSeconds, err := normalizeTunnelTTL(input.GetTtlSeconds()) + if err != nil { + return nil, err + } + now := time.Now() + var expiresAt time.Time + if ttlSeconds > 0 { + expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) + } + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + activeCount := 0 + for _, record := range m.tunnels.tunnelsByID { + if record == nil || record.closed || isTunnelExpired(record, now) { + continue + } + activeCount += 1 + } + if activeCount >= MaxTunnelsPerAgent { + return nil, ErrTunnelLimitExceeded + } + + id := strings.TrimSpace(input.GetTunnelId()) + if id == "" { + id = generateTunnelID() + } + if _, exists := m.tunnels.tunnelsByID[id]; exists { + return nil, fmt.Errorf("tunnel id already exists") + } + slug := strings.TrimSpace(input.GetSlug()) + if slug == "" { + for { + generated, err := generateTunnelSlug() + if err != nil { + return nil, err + } + if _, exists := m.tunnels.tunnelIDBySlug[generated]; !exists { + slug = generated + break + } + } + } else if _, exists := m.tunnels.tunnelIDBySlug[slug]; exists { + return nil, fmt.Errorf("tunnel slug already exists") + } + + publicURL := normalizeTunnelPublicURL(input.GetPublicUrl()) + if publicURL == "" { + publicURL = buildTunnelPublicURL(publicBaseURL, slug) + } + + return &gatewayv1.TunnelControlRequest{ + Action: strings.TrimSpace(input.GetAction()), + TunnelId: id, + Slug: slug, + TargetUrl: strings.TrimSpace(input.GetTargetUrl()), + Name: strings.TrimSpace(input.GetName()), + TtlSeconds: ttlSeconds, + ExpiresAt: tunnelUnix(expiresAt), + PublicUrl: publicURL, + PublicBaseUrl: strings.TrimSpace(publicBaseURL), + ProjectPathKey: strings.TrimSpace(input.GetProjectPathKey()), + }, nil +} + +func (m *Manager) PrepareTunnelUpdate( + input *gatewayv1.TunnelControlRequest, +) (*gatewayv1.TunnelControlRequest, error) { + if input == nil { + return nil, errors.New("tunnel update input is required") + } + ttlSeconds, err := normalizeTunnelTTL(input.GetTtlSeconds()) + if err != nil { + return nil, err + } + now := time.Now() + var expiresAt time.Time + if input.GetExpiresAt() > 0 { + expiresAt = time.Unix(input.GetExpiresAt(), 0) + } else if ttlSeconds > 0 { + expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) + } + targetURL := strings.TrimSpace(input.GetTargetUrl()) + if targetURL == "" { + return nil, errors.New("target_url is required") + } + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + identifier := strings.TrimSpace(input.GetTunnelId()) + if identifier == "" { + identifier = strings.TrimSpace(input.GetSlug()) + } + if identifier == "" { + return nil, ErrTunnelNotFound + } + tunnelID := identifier + if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { + tunnelID = bySlug + } + record := m.tunnels.tunnelsByID[tunnelID] + if record == nil || record.closed { + return nil, ErrTunnelNotFound + } + if isTunnelExpired(record, now) { + return nil, ErrTunnelExpired + } + projectPathKey := strings.TrimSpace(input.GetProjectPathKey()) + if projectPathKey == "" { + projectPathKey = record.projectPathKey + } + + return &gatewayv1.TunnelControlRequest{ + Action: strings.TrimSpace(input.GetAction()), + TunnelId: record.id, + Slug: record.slug, + TargetUrl: targetURL, + Name: strings.TrimSpace(input.GetName()), + TtlSeconds: ttlSeconds, + ExpiresAt: tunnelUnix(expiresAt), + PublicUrl: record.publicURL, + ProjectPathKey: projectPathKey, + }, nil +} + +func (m *Manager) StorePreparedTunnel( + prepared *gatewayv1.TunnelControlRequest, + targetURLOverride string, +) (*gatewayv1.TunnelSummary, error) { + if prepared == nil { + return nil, errors.New("prepared tunnel is required") + } + now := time.Now() + targetURL := strings.TrimSpace(targetURLOverride) + if targetURL == "" { + targetURL = strings.TrimSpace(prepared.GetTargetUrl()) + } + if targetURL == "" { + return nil, errors.New("target_url is required") + } + var expiresAt time.Time + if prepared.GetExpiresAt() > 0 { + expiresAt = time.Unix(prepared.GetExpiresAt(), 0) + } else if prepared.GetTtlSeconds() > 0 { + ttlSeconds, err := normalizeTunnelTTL(prepared.GetTtlSeconds()) + if err != nil { + return nil, err + } + expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) + } + record := &tunnelRecord{ + id: strings.TrimSpace(prepared.GetTunnelId()), + slug: strings.TrimSpace(prepared.GetSlug()), + name: strings.TrimSpace(prepared.GetName()), + targetURL: targetURL, + publicURL: normalizeTunnelPublicURL(prepared.GetPublicUrl()), + projectPathKey: strings.TrimSpace(prepared.GetProjectPathKey()), + createdAt: now, + expiresAt: expiresAt, + } + if record.id == "" || record.slug == "" { + return nil, errors.New("prepared tunnel is missing id or slug") + } + if record.publicURL == "" { + record.publicURL = buildTunnelPublicURL(prepared.GetPublicBaseUrl(), record.slug) + } + + online := m.IsOnline() + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + if _, exists := m.tunnels.tunnelsByID[record.id]; exists { + return nil, fmt.Errorf("tunnel id already exists") + } + if _, exists := m.tunnels.tunnelIDBySlug[record.slug]; exists { + return nil, fmt.Errorf("tunnel slug already exists") + } + m.tunnels.tunnelsByID[record.id] = record + m.tunnels.tunnelIDBySlug[record.slug] = record.id + return tunnelSummaryLocked(record, now, online), nil +} + +func (m *Manager) CreateTunnelFromAgent( + input *gatewayv1.TunnelControlRequest, +) (*gatewayv1.TunnelSummary, error) { + prepared, err := m.PrepareTunnelCreate(input, input.GetPublicBaseUrl()) + if err != nil { + return nil, err + } + return m.StorePreparedTunnel(prepared, input.GetTargetUrl()) +} + +func (m *Manager) UpdateTunnelFromAgent( + input *gatewayv1.TunnelControlRequest, +) (*gatewayv1.TunnelSummary, error) { + prepared, err := m.PrepareTunnelUpdate(input) + if err != nil { + return nil, err + } + return m.ApplyTunnelUpdate(&gatewayv1.TunnelSummary{ + Id: prepared.GetTunnelId(), + Slug: prepared.GetSlug(), + Name: prepared.GetName(), + TargetUrl: prepared.GetTargetUrl(), + PublicUrl: prepared.GetPublicUrl(), + ExpiresAt: prepared.GetExpiresAt(), + ProjectPathKey: prepared.GetProjectPathKey(), + }) +} + +func (m *Manager) ApplyTunnelUpdate(summary *gatewayv1.TunnelSummary) (*gatewayv1.TunnelSummary, error) { + if summary == nil { + return nil, errors.New("tunnel update summary is required") + } + identifier := strings.TrimSpace(summary.GetId()) + if identifier == "" { + identifier = strings.TrimSpace(summary.GetSlug()) + } + if identifier == "" { + return nil, ErrTunnelNotFound + } + targetURL := strings.TrimSpace(summary.GetTargetUrl()) + if targetURL == "" { + return nil, errors.New("target_url is required") + } + now := time.Now() + online := m.IsOnline() + var expiresAt time.Time + if summary.GetExpiresAt() > 0 { + expiresAt = time.Unix(summary.GetExpiresAt(), 0) + } + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + tunnelID := identifier + if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { + tunnelID = bySlug + } + record := m.tunnels.tunnelsByID[tunnelID] + if record == nil || record.closed { + return nil, ErrTunnelNotFound + } + if isTunnelExpired(record, now) { + return nil, ErrTunnelExpired + } + record.name = strings.TrimSpace(summary.GetName()) + record.targetURL = targetURL + if publicURL := normalizeTunnelPublicURL(summary.GetPublicUrl()); publicURL != "" { + record.publicURL = publicURL + } + record.projectPathKey = strings.TrimSpace(summary.GetProjectPathKey()) + record.expiresAt = expiresAt + return tunnelSummaryLocked(record, now, online), nil +} + +func (m *Manager) AcquireTunnel(slug string, streamID string) (*TunnelStreamLease, error) { + slug = strings.TrimSpace(slug) + streamID = strings.TrimSpace(streamID) + if slug == "" || streamID == "" { + return nil, ErrTunnelNotFound + } + if !m.IsOnline() { + return nil, ErrAgentOffline + } + now := time.Now() + online := true + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + tunnelID := m.tunnels.tunnelIDBySlug[slug] + record := m.tunnels.tunnelsByID[tunnelID] + if record == nil || record.closed { + return nil, ErrTunnelNotFound + } + if isTunnelExpired(record, now) { + return nil, ErrTunnelExpired + } + if record.activeConnections >= MaxTunnelConnections { + return nil, ErrTunnelOverLimit + } + stream := &tunnelStream{ + streamID: streamID, + tunnelID: record.id, + ch: make(chan *gatewayv1.TunnelFrame, tunnelStreamChannelDepth), + done: make(chan struct{}), + } + if existing := m.tunnels.streams[streamID]; existing != nil { + existing.close() + } + m.tunnels.streams[streamID] = stream + record.activeConnections += 1 + + return &TunnelStreamLease{ + manager: m, + stream: stream, + tunnel: tunnelSummaryLocked(record, now, online), + }, nil +} + +func (m *Manager) CloseTunnel(identifier string) (*gatewayv1.TunnelSummary, error) { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return nil, ErrTunnelNotFound + } + now := time.Now() + online := m.IsOnline() + + var summary *gatewayv1.TunnelSummary + var cancelFrames []*gatewayv1.TunnelFrame + m.tunnels.mu.Lock() + tunnelID := identifier + if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { + tunnelID = bySlug + } + record := m.tunnels.tunnelsByID[tunnelID] + if record == nil || record.closed { + m.tunnels.mu.Unlock() + return nil, ErrTunnelNotFound + } + record.closed = true + summary = tunnelSummaryLocked(record, now, online) + delete(m.tunnels.tunnelsByID, record.id) + delete(m.tunnels.tunnelIDBySlug, record.slug) + for streamID, stream := range m.tunnels.streams { + if stream == nil || stream.tunnelID != record.id { + continue + } + delete(m.tunnels.streams, streamID) + stream.close() + cancelFrames = append(cancelFrames, &gatewayv1.TunnelFrame{ + StreamId: stream.streamID, + TunnelId: record.id, + Slug: record.slug, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + }) + } + m.tunnels.mu.Unlock() + + for _, frame := range cancelFrames { + _ = m.SendTunnelFrameToAgent(frame) + } + return summary, nil +} + +func (m *Manager) ResumeTunnel(input *gatewayv1.TunnelControlRequest) (*gatewayv1.TunnelSummary, error) { + if input == nil { + return nil, errors.New("resume tunnel input is required") + } + now := time.Now() + online := m.IsOnline() + id := strings.TrimSpace(input.GetTunnelId()) + slug := strings.TrimSpace(input.GetSlug()) + if id == "" && slug == "" { + return nil, ErrTunnelNotFound + } + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + if id == "" { + id = m.tunnels.tunnelIDBySlug[slug] + } + record := m.tunnels.tunnelsByID[id] + if record == nil || record.closed { + return nil, ErrTunnelNotFound + } + if slug != "" && record.slug != slug { + return nil, ErrTunnelNotFound + } + if isTunnelExpired(record, now) { + return nil, ErrTunnelExpired + } + if targetURL := strings.TrimSpace(input.GetTargetUrl()); targetURL != "" { + record.targetURL = targetURL + } + if name := strings.TrimSpace(input.GetName()); name != "" { + record.name = name + } + if projectPathKey := strings.TrimSpace(input.GetProjectPathKey()); projectPathKey != "" { + record.projectPathKey = projectPathKey + } + return tunnelSummaryLocked(record, now, online), nil +} + +func (m *Manager) SendTunnelFrameToAgent(frame *gatewayv1.TunnelFrame) error { + if frame == nil { + return errors.New("tunnel frame is required") + } + ctx, cancel := context.WithTimeout(context.Background(), tunnelAgentSendTimeout) + defer cancel() + return m.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ + RequestId: fmt.Sprintf("tunnel-frame-%s", strings.TrimSpace(frame.GetStreamId())), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelFrame{ + TunnelFrame: frame, + }, + }) +} + +func (m *Manager) dispatchTunnelFrame(frame *gatewayv1.TunnelFrame) { + if frame == nil { + return + } + streamID := strings.TrimSpace(frame.GetStreamId()) + if streamID == "" { + return + } + m.tunnels.mu.Lock() + stream := m.tunnels.streams[streamID] + m.tunnels.mu.Unlock() + if stream == nil { + return + } + stream.send(frame) +} + +func (m *Manager) handleAgentTunnelControl( + session *AgentSession, + requestID string, + request *gatewayv1.TunnelControlRequest, +) { + if session == nil || request == nil { + return + } + response := m.handleAgentTunnelControlInner(request) + ctx, cancel := context.WithTimeout(context.Background(), tunnelAgentSendTimeout) + defer cancel() + _ = session.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ + RequestId: requestID, + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelControlResp{ + TunnelControlResp: response, + }, + }) +} + +func (m *Manager) handleAgentTunnelControlInner( + request *gatewayv1.TunnelControlRequest, +) *gatewayv1.TunnelControlResponse { + action := strings.ToLower(strings.TrimSpace(request.GetAction())) + if action == "" { + return tunnelControlError("invalid_action", "tunnel action is required") + } + switch action { + case "list": + return &gatewayv1.TunnelControlResponse{ + Action: action, + Tunnels: m.ListTunnels(), + } + case "create": + tunnel, err := m.CreateTunnelFromAgent(request) + if err != nil { + return tunnelControlErrorFor(action, err) + } + return &gatewayv1.TunnelControlResponse{ + Action: action, + Tunnel: tunnel, + Tunnels: m.ListTunnels(), + } + case "update": + tunnel, err := m.UpdateTunnelFromAgent(request) + if err != nil { + return tunnelControlErrorFor(action, err) + } + return &gatewayv1.TunnelControlResponse{ + Action: action, + Tunnel: tunnel, + Tunnels: m.ListTunnels(), + } + case "close": + identifier := request.GetTunnelId() + if strings.TrimSpace(identifier) == "" { + identifier = request.GetSlug() + } + tunnel, err := m.CloseTunnel(identifier) + if err != nil { + return tunnelControlErrorFor(action, err) + } + return &gatewayv1.TunnelControlResponse{ + Action: action, + Tunnel: tunnel, + Tunnels: m.ListTunnels(), + } + case "resume": + tunnel, err := m.ResumeTunnel(request) + if err != nil { + return tunnelControlErrorFor(action, err) + } + return &gatewayv1.TunnelControlResponse{ + Action: action, + Tunnel: tunnel, + Tunnels: m.ListTunnels(), + } + default: + return tunnelControlError("invalid_action", "unsupported tunnel action") + } +} + +func (m *Manager) releaseTunnelStream(stream *tunnelStream) { + if stream == nil { + return + } + m.tunnels.mu.Lock() + if existing := m.tunnels.streams[stream.streamID]; existing == stream { + delete(m.tunnels.streams, stream.streamID) + } + if record := m.tunnels.tunnelsByID[stream.tunnelID]; record != nil && record.activeConnections > 0 { + record.activeConnections -= 1 + } + stream.close() + m.tunnels.mu.Unlock() +} + +func normalizeTunnelTTL(input uint32) (uint32, error) { + switch input { + case 0: + return 0, nil + case 900, 3600, 14400: + return input, nil + default: + return 0, errors.New("ttl_seconds must be one of 0, 900, 3600, or 14400") + } +} + +func tunnelUnix(value time.Time) int64 { + if value.IsZero() { + return 0 + } + return value.Unix() +} + +func generateTunnelID() string { + return "tun_" + strings.ReplaceAll(time.Now().UTC().Format("20060102150405.000000000"), ".", "") + "_" + randomURLToken(8) +} + +func generateTunnelSlug() (string, error) { + token := randomURLToken(tunnelSlugEntropyBytes) + if token == "" { + return "", errors.New("generate tunnel slug failed") + } + return token, nil +} + +func randomURLToken(byteCount int) string { + if byteCount <= 0 { + return "" + } + buf := make([]byte, byteCount) + if _, err := rand.Read(buf); err != nil { + return "" + } + return base64.RawURLEncoding.EncodeToString(buf) +} + +func normalizeTunnelPublicURL(input string) string { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "" + } + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + parsed.RawQuery = "" + parsed.Fragment = "" + if !strings.HasSuffix(parsed.Path, "/") { + parsed.Path += "/" + } + return parsed.String() +} + +func buildTunnelPublicURL(publicBaseURL string, slug string) string { + base := strings.TrimSpace(publicBaseURL) + if base == "" || strings.TrimSpace(slug) == "" { + return "" + } + parsed, err := url.Parse(base) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + parsed.RawQuery = "" + parsed.Fragment = "" + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/t/" + strings.TrimSpace(slug) + "/" + return parsed.String() +} + +func isTunnelExpired(record *tunnelRecord, now time.Time) bool { + return record == nil || (!record.expiresAt.IsZero() && !record.expiresAt.After(now)) +} + +func tunnelSummaryLocked(record *tunnelRecord, now time.Time, online bool) *gatewayv1.TunnelSummary { + if record == nil { + return &gatewayv1.TunnelSummary{Status: "expired"} + } + status := "active" + if record.closed || isTunnelExpired(record, now) { + status = "expired" + } else if !online { + status = "offline" + } + activeConnections := uint32(0) + if record.activeConnections > 0 { + activeConnections = uint32(record.activeConnections) + } + return &gatewayv1.TunnelSummary{ + Id: record.id, + Slug: record.slug, + Name: record.name, + TargetUrl: record.targetURL, + PublicUrl: record.publicURL, + CreatedAt: record.createdAt.Unix(), + ExpiresAt: tunnelUnix(record.expiresAt), + ActiveConnections: activeConnections, + Status: status, + ProjectPathKey: record.projectPathKey, + } +} + +func cloneTunnelSummary(summary *gatewayv1.TunnelSummary) *gatewayv1.TunnelSummary { + if summary == nil { + return nil + } + return &gatewayv1.TunnelSummary{ + Id: summary.GetId(), + Slug: summary.GetSlug(), + Name: summary.GetName(), + TargetUrl: summary.GetTargetUrl(), + PublicUrl: summary.GetPublicUrl(), + CreatedAt: summary.GetCreatedAt(), + ExpiresAt: summary.GetExpiresAt(), + ActiveConnections: summary.GetActiveConnections(), + Status: summary.GetStatus(), + ProjectPathKey: strings.TrimSpace(summary.GetProjectPathKey()), + } +} + +func sortTunnelSummaries(summaries []*gatewayv1.TunnelSummary) { + for i := 1; i < len(summaries); i++ { + current := summaries[i] + j := i - 1 + for j >= 0 && summaries[j].GetCreatedAt() > current.GetCreatedAt() { + summaries[j+1] = summaries[j] + j-- + } + summaries[j+1] = current + } +} + +func tunnelControlError(code string, message string) *gatewayv1.TunnelControlResponse { + return &gatewayv1.TunnelControlResponse{ + ErrorCode: strings.TrimSpace(code), + ErrorMessage: strings.TrimSpace(message), + } +} + +func tunnelControlErrorFor(action string, err error) *gatewayv1.TunnelControlResponse { + code := "failed" + switch { + case errors.Is(err, ErrTunnelNotFound): + code = "not_found" + case errors.Is(err, ErrTunnelExpired): + code = "expired" + case errors.Is(err, ErrTunnelLimitExceeded): + code = "limit_exceeded" + case errors.Is(err, ErrTunnelOverLimit): + code = "over_limit" + case errors.Is(err, ErrAgentOffline): + code = "agent_offline" + } + return &gatewayv1.TunnelControlResponse{ + Action: strings.TrimSpace(action), + ErrorCode: code, + ErrorMessage: err.Error(), + } +} diff --git a/crates/agent-gateway/internal/session/manager_tunnel_test.go b/crates/agent-gateway/internal/session/manager_tunnel_test.go new file mode 100644 index 000000000..1e147a0b3 --- /dev/null +++ b/crates/agent-gateway/internal/session/manager_tunnel_test.go @@ -0,0 +1,265 @@ +package session + +import ( + "errors" + "testing" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func onlineTunnelTestManager() *Manager { + manager := NewManager() + manager.SetSession(NewAgentSession(AuthSnapshot{ + AgentID: "agent-a", + AgentVersion: "test", + SessionID: "session-a", + })) + return manager +} + +func createTestTunnel(t *testing.T, manager *Manager, name string) *gatewayv1.TunnelSummary { + t.Helper() + tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "create", + TargetUrl: "http://localhost:3000/app", + Name: name, + TtlSeconds: 3600, + PublicBaseUrl: "https://gateway.example", + }) + if err != nil { + t.Fatalf("CreateTunnelFromAgent: %v", err) + } + if tunnel.GetSlug() == "" || tunnel.GetPublicUrl() == "" { + t.Fatalf("created tunnel missing slug/public URL: %+v", tunnel) + } + return tunnel +} + +func TestTunnelRegistryCreateLimitListAndClose(t *testing.T) { + manager := onlineTunnelTestManager() + + var first *gatewayv1.TunnelSummary + for i := 0; i < MaxTunnelsPerAgent; i++ { + tunnel := createTestTunnel(t, manager, "app") + if i == 0 { + first = tunnel + } + } + + if _, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "create", + TargetUrl: "http://localhost:3001", + TtlSeconds: 3600, + PublicBaseUrl: "https://gateway.example", + }); !errors.Is(err, ErrTunnelLimitExceeded) { + t.Fatalf("expected ErrTunnelLimitExceeded, got %v", err) + } + + if got := len(manager.ListTunnels()); got != MaxTunnelsPerAgent { + t.Fatalf("ListTunnels returned %d tunnels, want %d", got, MaxTunnelsPerAgent) + } + + closed, err := manager.CloseTunnel(first.GetId()) + if err != nil { + t.Fatalf("CloseTunnel: %v", err) + } + if closed.GetStatus() != "expired" { + t.Fatalf("closed tunnel summary status = %q, want expired", closed.GetStatus()) + } + if got := len(manager.ListTunnels()); got != MaxTunnelsPerAgent-1 { + t.Fatalf("ListTunnels after close returned %d tunnels, want %d", got, MaxTunnelsPerAgent-1) + } +} + +func TestTunnelAcquireConnectionLimitAndRelease(t *testing.T) { + manager := onlineTunnelTestManager() + tunnel := createTestTunnel(t, manager, "app") + + leases := make([]*TunnelStreamLease, 0, MaxTunnelConnections) + for i := 0; i < MaxTunnelConnections; i++ { + lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-"+string(rune('a'+i))) + if err != nil { + t.Fatalf("AcquireTunnel %d: %v", i, err) + } + leases = append(leases, lease) + } + if _, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-over-limit"); !errors.Is(err, ErrTunnelOverLimit) { + t.Fatalf("expected ErrTunnelOverLimit, got %v", err) + } + + leases[0].Release() + lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-after-release") + if err != nil { + t.Fatalf("AcquireTunnel after release: %v", err) + } + lease.Release() + for _, item := range leases[1:] { + item.Release() + } + + summaries := manager.ListTunnels() + if len(summaries) != 1 { + t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) + } + if got := summaries[0].GetActiveConnections(); got != 0 { + t.Fatalf("active connections after release = %d, want 0", got) + } +} + +func TestTunnelExpiredCannotBeAcquired(t *testing.T) { + manager := onlineTunnelTestManager() + tunnel := createTestTunnel(t, manager, "app") + + manager.tunnels.mu.Lock() + manager.tunnels.tunnelsByID[tunnel.GetId()].expiresAt = time.Now().Add(-time.Second) + manager.tunnels.mu.Unlock() + + if _, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-expired"); !errors.Is(err, ErrTunnelExpired) { + t.Fatalf("expected ErrTunnelExpired, got %v", err) + } + summaries := manager.ListTunnels() + if len(summaries) != 1 { + t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) + } + if summaries[0].GetStatus() != "expired" { + t.Fatalf("expired tunnel status = %q, want expired", summaries[0].GetStatus()) + } +} + +func TestTunnelInfiniteTTLCreatesNonExpiringTunnel(t *testing.T) { + manager := onlineTunnelTestManager() + tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "create", + TargetUrl: "http://localhost:3000/app", + Name: "app", + TtlSeconds: 0, + PublicBaseUrl: "https://gateway.example", + }) + if err != nil { + t.Fatalf("CreateTunnelFromAgent with infinite TTL: %v", err) + } + if tunnel.GetExpiresAt() != 0 { + t.Fatalf("infinite tunnel expiresAt = %d, want 0", tunnel.GetExpiresAt()) + } + if tunnel.GetStatus() != "active" { + t.Fatalf("infinite tunnel status = %q, want active", tunnel.GetStatus()) + } + + manager.tunnels.mu.Lock() + manager.tunnels.tunnelsByID[tunnel.GetId()].expiresAt = time.Time{} + manager.tunnels.mu.Unlock() + + lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-infinite") + if err != nil { + t.Fatalf("AcquireTunnel for infinite tunnel: %v", err) + } + lease.Release() +} + +func TestTunnelUpdateChangesTargetNameScopeAndTTL(t *testing.T) { + manager := onlineTunnelTestManager() + tunnel := createTestTunnel(t, manager, "app") + + updated, err := manager.UpdateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "update", + TunnelId: tunnel.GetId(), + TargetUrl: "http://127.0.0.1:4000/dashboard", + Name: "dashboard", + TtlSeconds: 0, + ProjectPathKey: "project:/tmp/liveagent", + }) + if err != nil { + t.Fatalf("UpdateTunnelFromAgent: %v", err) + } + if updated.GetName() != "dashboard" { + t.Fatalf("updated name = %q, want dashboard", updated.GetName()) + } + if updated.GetTargetUrl() != "http://127.0.0.1:4000/dashboard" { + t.Fatalf("updated target = %q", updated.GetTargetUrl()) + } + if updated.GetExpiresAt() != 0 { + t.Fatalf("updated expiresAt = %d, want 0", updated.GetExpiresAt()) + } + if updated.GetProjectPathKey() != "project:/tmp/liveagent" { + t.Fatalf("updated projectPathKey = %q", updated.GetProjectPathKey()) + } + + listed := manager.ListTunnels() + if len(listed) != 1 { + t.Fatalf("ListTunnels returned %d tunnels, want 1", len(listed)) + } + if listed[0].GetId() != tunnel.GetId() || listed[0].GetTargetUrl() != updated.GetTargetUrl() { + t.Fatalf("ListTunnels did not include updated tunnel: %+v", listed[0]) + } +} + +func TestTunnelInfiniteTTLStaysActiveAndVisible(t *testing.T) { + manager := onlineTunnelTestManager() + + tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "create", + TargetUrl: "http://localhost:3000/app", + Name: "app", + TtlSeconds: 0, + PublicBaseUrl: "https://gateway.example", + ProjectPathKey: "/workspace/app", + }) + if err != nil { + t.Fatalf("CreateTunnelFromAgent: %v", err) + } + if tunnel.GetExpiresAt() != 0 { + t.Fatalf("infinite tunnel expires_at = %d, want 0", tunnel.GetExpiresAt()) + } + if tunnel.GetProjectPathKey() != "/workspace/app" { + t.Fatalf("project_path_key = %q, want /workspace/app", tunnel.GetProjectPathKey()) + } + + summaries := manager.ListTunnels() + if len(summaries) != 1 { + t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) + } + if summaries[0].GetStatus() != "active" { + t.Fatalf("infinite tunnel status = %q, want active", summaries[0].GetStatus()) + } + if summaries[0].GetExpiresAt() != 0 { + t.Fatalf("listed infinite tunnel expires_at = %d, want 0", summaries[0].GetExpiresAt()) + } +} + +func TestTunnelUpdateChangesTargetNameTTLAndKeepsProjectScope(t *testing.T) { + manager := onlineTunnelTestManager() + tunnel := createTestTunnel(t, manager, "app") + + updated, err := manager.UpdateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + Action: "update", + TunnelId: tunnel.GetId(), + TargetUrl: "http://localhost:3000/next", + Name: "next", + TtlSeconds: 0, + ProjectPathKey: "/workspace/app", + }) + if err != nil { + t.Fatalf("UpdateTunnelFromAgent: %v", err) + } + if updated.GetTargetUrl() != "http://localhost:3000/next" { + t.Fatalf("target_url = %q, want http://localhost:3000/next", updated.GetTargetUrl()) + } + if updated.GetName() != "next" { + t.Fatalf("name = %q, want next", updated.GetName()) + } + if updated.GetExpiresAt() != 0 { + t.Fatalf("updated expires_at = %d, want 0", updated.GetExpiresAt()) + } + if updated.GetProjectPathKey() != "/workspace/app" { + t.Fatalf("project_path_key = %q, want /workspace/app", updated.GetProjectPathKey()) + } + + listed := manager.ListTunnels() + if len(listed) != 1 { + t.Fatalf("ListTunnels returned %d tunnels, want 1", len(listed)) + } + if listed[0].GetTargetUrl() != "http://localhost:3000/next" { + t.Fatalf("listed target_url = %q, want http://localhost:3000/next", listed[0].GetTargetUrl()) + } +} diff --git a/crates/agent-gateway/proto/v1/gateway.proto b/crates/agent-gateway/proto/v1/gateway.proto index 94a0df3c6..d15952d75 100644 --- a/crates/agent-gateway/proto/v1/gateway.proto +++ b/crates/agent-gateway/proto/v1/gateway.proto @@ -63,6 +63,9 @@ message GatewayEnvelope { GitRequest git_request = 61; FsReadEditableTextRequest fs_read_editable_text = 62; FsReadWorkspaceImageRequest fs_read_workspace_image = 63; + TunnelControlRequest tunnel_control = 67; + TunnelControlResponse tunnel_control_resp = 68; + TunnelFrame tunnel_frame = 69; } } @@ -110,6 +113,11 @@ message AgentEnvelope { GitResponse git_response = 64; FsReadEditableTextResponse fs_read_editable_text_resp = 65; FsReadWorkspaceImageResponse fs_read_workspace_image_resp = 66; + TunnelControlRequest tunnel_control = 67; + TunnelControlResponse tunnel_control_resp = 68; + TunnelFrame tunnel_frame = 69; + ChatControlEvent chat_control = 70; + RuntimeStatusEvent runtime_status = 71; ErrorResponse error = 99; } } @@ -160,6 +168,75 @@ message UploadedImagePreviewResponse { string data = 2; } +message TunnelControlRequest { + string action = 1; + string tunnel_id = 2; + string slug = 3; + string target_url = 4; + string name = 5; + uint32 ttl_seconds = 6; + int64 expires_at = 7; + string public_url = 8; + string public_base_url = 9; + string project_path_key = 10; +} + +message TunnelControlResponse { + string action = 1; + repeated TunnelSummary tunnels = 2; + TunnelSummary tunnel = 3; + string error_code = 4; + string error_message = 5; +} + +message TunnelSummary { + string id = 1; + string slug = 2; + string name = 3; + string target_url = 4; + string public_url = 5; + int64 created_at = 6; + int64 expires_at = 7; + uint32 active_connections = 8; + string status = 9; + string project_path_key = 10; +} + +message TunnelHeader { + string name = 1; + string value = 2; +} + +enum TunnelFrameKind { + TUNNEL_FRAME_KIND_UNSPECIFIED = 0; + TUNNEL_FRAME_KIND_HTTP_REQUEST_START = 1; + TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY = 2; + TUNNEL_FRAME_KIND_HTTP_REQUEST_END = 3; + TUNNEL_FRAME_KIND_HTTP_RESPONSE_START = 4; + TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY = 5; + TUNNEL_FRAME_KIND_HTTP_RESPONSE_END = 6; + TUNNEL_FRAME_KIND_WS_OPEN = 7; + TUNNEL_FRAME_KIND_WS_FRAME = 8; + TUNNEL_FRAME_KIND_WS_CLOSE = 9; + TUNNEL_FRAME_KIND_ERROR = 10; + TUNNEL_FRAME_KIND_CANCEL = 11; +} + +message TunnelFrame { + string stream_id = 1; + string tunnel_id = 2; + string slug = 3; + TunnelFrameKind kind = 4; + string method = 5; + string path = 6; + repeated TunnelHeader headers = 7; + uint32 status_code = 8; + bytes body = 9; + bool end_stream = 10; + string error = 11; + string ws_message_type = 12; +} + message MemoryManageRequest { string command = 1; string args_json = 2; @@ -270,6 +347,26 @@ message ChatEvent { } } +message ChatControlEvent { + string request_id = 1; + string client_request_id = 2; + string conversation_id = 3; + int64 run_epoch = 4; + string type = 5; + string state = 6; + string error_code = 7; + string message = 8; + int64 seq = 9; +} + +message RuntimeStatusEvent { + string worker_id = 1; + string state = 2; + bool visible = 3; + uint32 active_run_count = 4; + int64 timestamp = 5; +} + message CronManageRequest { string action = 1; string task_id = 2; diff --git a/crates/agent-gateway/test/session/manager_test.go b/crates/agent-gateway/test/session/manager_test.go index 53d5e645c..41d177d95 100644 --- a/crates/agent-gateway/test/session/manager_test.go +++ b/crates/agent-gateway/test/session/manager_test.go @@ -1,6 +1,7 @@ package session_test import ( + "context" "errors" "fmt" "strings" @@ -76,6 +77,122 @@ func TestClearSessionDoesNotCloseReplacement(t *testing.T) { } } +func TestClearSessionIfHeartbeatStaleClosesOnlyCurrentSession(t *testing.T) { + t.Parallel() + + sm := newTestSessionManager() + first := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(first) + second := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(second) + + time.Sleep(time.Millisecond) + if sm.ClearSessionIfHeartbeatStale(first, time.Nanosecond) { + t.Fatalf("stale first session should not close replacement session") + } + assertDoneOpen(t, second.Done()) + if status := sm.Status(); !status.Online { + t.Fatalf("status online = false after stale old-session heartbeat timeout") + } + + time.Sleep(time.Millisecond) + if !sm.ClearSessionIfHeartbeatStale(second, time.Nanosecond) { + t.Fatalf("current stale session was not cleared") + } + assertDoneClosed(t, second.Done()) + if status := sm.Status(); status.Online { + t.Fatalf("status online = true after current session heartbeat timeout") + } + if err := sm.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: "after-timeout"}); !errors.Is(err, session.ErrAgentOffline) { + t.Fatalf("SendToAgent after heartbeat timeout = %v, want ErrAgentOffline", err) + } +} + +func TestChatRuntimeReadyRequiresFreshRuntimeHeartbeat(t *testing.T) { + t.Parallel() + + sm := newTestSessionManager() + sess := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(sess) + + if status := sm.Status(); !status.Online || status.ChatRuntimeReady { + t.Fatalf("initial status = %#v, want online without chat runtime readiness", status) + } + + sm.UpdateRuntimeStatus(sess, &gatewayv1.RuntimeStatusEvent{ + WorkerId: "runtime-1", + State: "ready", + Visible: true, + ActiveRunCount: 0, + Timestamp: time.Now().Unix(), + }) + if status := sm.Status(); !status.ChatRuntimeReady || + status.RuntimeState != "ready" || + status.RuntimeWorkerID != "runtime-1" || + status.RuntimeLastHeartbeat == 0 { + t.Fatalf("ready runtime status = %#v", status) + } + + sm.UpdateRuntimeStatus(sess, &gatewayv1.RuntimeStatusEvent{ + WorkerId: "runtime-1", + State: "suspended", + Timestamp: time.Now().Unix(), + }) + if status := sm.Status(); status.ChatRuntimeReady || status.RuntimeState != "suspended" { + t.Fatalf("suspended runtime status = %#v, want not ready", status) + } + + sm.UpdateRuntimeStatus(sess, &gatewayv1.RuntimeStatusEvent{ + WorkerId: "runtime-1", + State: "busy", + Timestamp: time.Now().Unix(), + }) + if !sm.ChatRuntimeReady() { + t.Fatalf("busy runtime should be ready to manage chat runs") + } + + sm.ClearSession(sess) + if status := sm.Status(); status.ChatRuntimeReady || status.RuntimeState != "" { + t.Fatalf("cleared session status = %#v, want runtime readiness reset", status) + } +} + +func TestClearSessionIfHeartbeatStaleFailsOpenChatRuns(t *testing.T) { + t.Parallel() + + sm := newTestSessionManager() + sess := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(sess) + if _, _, err := sm.StartPendingChatRunWithClientRequest( + "request-1", + "conversation-1", + "client-submit-1", + ); err != nil { + t.Fatalf("StartPendingChatRunWithClientRequest: %v", err) + } + ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) + if err != nil { + t.Fatalf("SubscribeChatRun: %v", err) + } + defer cleanup() + + time.Sleep(time.Millisecond) + if !sm.ClearSessionIfHeartbeatStale(sess, time.Nanosecond) { + t.Fatalf("current stale session was not cleared") + } + select { + case event := <-ch: + if event.Event.GetType() != gatewayv1.ChatEvent_ERROR { + t.Fatalf("event type = %v, want ERROR", event.Event.GetType()) + } + if !strings.Contains(event.Event.GetData(), "Desktop agent disconnected") { + t.Fatalf("event data = %q", event.Event.GetData()) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for heartbeat timeout chat error") + } +} + func TestDispatchFromStaleSessionIsIgnored(t *testing.T) { t.Parallel() @@ -158,6 +275,31 @@ func TestSendToAgentUnblocksWhenSessionCloses(t *testing.T) { } } +func TestSendToAgentContextReturnsWhenOutboundQueueIsFull(t *testing.T) { + t.Parallel() + + sm := newTestSessionManager() + sess := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(sess) + + for i := 0; i < 64; i += 1 { + if err := sm.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("queued-%d", i)}); err != nil { + t.Fatalf("prime outbound queue: %v", err) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + err := sm.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{RequestId: "blocked"}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("SendToAgentContext with full queue = %v, want context deadline exceeded", err) + } + if status := sm.Status(); status.Online { + t.Fatalf("status online = true after SendToAgentContext timeout") + } +} + func TestRemoveChatRunByConversationReleasesBufferedRun(t *testing.T) { t.Parallel() @@ -252,6 +394,251 @@ func TestStartChatRunWithClientRequestReusesExistingRun(t *testing.T) { } } +func TestPendingChatRunBecomesActiveOnlyAfterStartedEvent(t *testing.T) { + t.Parallel() + + sm := newTestSessionManager() + sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) + snapshot, created, err := sm.StartPendingChatRunWithClientRequest( + "request-1", + "conversation-1", + "client-submit-1", + "/workspace", + ) + if err != nil { + t.Fatalf("StartPendingChatRunWithClientRequest: %v", err) + } + if !created || snapshot.RequestID != "request-1" { + t.Fatalf("pending run = %#v created=%v", snapshot, created) + } + if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { + t.Fatalf("pending active chat runs = %#v, want empty", got) + } + + ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) + if err != nil { + t.Fatalf("SubscribeChatRun: %v", err) + } + defer cleanup() + + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "request-1", + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: "conversation-1", + Data: `{"type":"accepted"}`, + }, + }, + }) + if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { + t.Fatalf("accepted active chat runs = %#v, want empty", got) + } + if sm.FailStartingChatRun("request-1", "desktop did not accept") { + t.Fatalf("accepted pending run should not fail the accept watchdog") + } + + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "request-1", + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: "conversation-1", + Data: `{"type":"started"}`, + }, + }, + }) + + got := sm.ActiveChatRunConversationIDs() + want := []string{"conversation-1"} + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("active chat runs after started = %#v, want %#v", got, want) + } + select { + case event := <-ch: + t.Fatalf("started control event leaked to subscriber: %#v", event) + case <-time.After(50 * time.Millisecond): + } +} + +func TestFailStartingChatRunBroadcastsErrorAndClearsActiveSummary(t *testing.T) { + t.Parallel() + + sm := newTestSessionManager() + sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) + if _, _, err := sm.StartPendingChatRunWithClientRequest( + "request-1", + "conversation-1", + "client-submit-1", + ); err != nil { + t.Fatalf("StartPendingChatRunWithClientRequest: %v", err) + } + ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) + if err != nil { + t.Fatalf("SubscribeChatRun: %v", err) + } + defer cleanup() + + if !sm.FailStartingChatRun("request-1", "desktop did not accept") { + t.Fatalf("FailStartingChatRun returned false") + } + + select { + case event := <-ch: + if event.Event.GetType() != gatewayv1.ChatEvent_ERROR { + t.Fatalf("event type = %v, want ERROR", event.Event.GetType()) + } + if !strings.Contains(event.Event.GetData(), "desktop did not accept") { + t.Fatalf("event data = %q", event.Event.GetData()) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for starting run failure event") + } + if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { + t.Fatalf("active chat runs after failed start = %#v, want empty", got) + } + if status := sm.Status(); status.Online { + t.Fatalf("status online = true after chat run failed before desktop accept") + } +} + +func TestFailUnstartedChatRunBroadcastsErrorUnlessStarted(t *testing.T) { + t.Parallel() + + sm := newTestSessionManager() + sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) + if _, _, err := sm.StartPendingChatRunWithClientRequest( + "request-1", + "conversation-1", + "client-submit-1", + ); err != nil { + t.Fatalf("StartPendingChatRunWithClientRequest request-1: %v", err) + } + ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) + if err != nil { + t.Fatalf("SubscribeChatRun: %v", err) + } + defer cleanup() + if sm.FailUnstartedChatRun("request-1", "desktop app did not start") { + t.Fatalf("unaccepted pending run should not fail the render-start watchdog") + } + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "request-1", + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: "conversation-1", + Data: `{"type":"accepted"}`, + }, + }, + }) + + if !sm.FailUnstartedChatRun("request-1", "desktop app did not start") { + t.Fatalf("FailUnstartedChatRun returned false for accepted pending run") + } + select { + case event := <-ch: + if event.Event.GetType() != gatewayv1.ChatEvent_ERROR { + t.Fatalf("event type = %v, want ERROR", event.Event.GetType()) + } + if !strings.Contains(event.Event.GetData(), "desktop app did not start") { + t.Fatalf("event data = %q", event.Event.GetData()) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for unstarted run failure event") + } + + if _, _, err := sm.StartPendingChatRunWithClientRequest( + "request-2", + "conversation-2", + "client-submit-2", + ); err != nil { + t.Fatalf("StartPendingChatRunWithClientRequest request-2: %v", err) + } + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "request-2", + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: "conversation-2", + Data: `{"type":"started"}`, + }, + }, + }) + if sm.FailUnstartedChatRun("request-2", "desktop app did not start") { + t.Fatalf("started run should not fail the render-start watchdog") + } +} + +func TestTerminalChatRunStateIsImmutable(t *testing.T) { + t.Parallel() + + sm := newTestSessionManager() + sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) + if _, _, err := sm.StartPendingChatRunWithClientRequest( + "request-1", + "conversation-1", + "client-submit-1", + ); err != nil { + t.Fatalf("StartPendingChatRunWithClientRequest: %v", err) + } + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "request-1", + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_ERROR, + ConversationId: "conversation-1", + Data: `{"message":"startup failed"}`, + }, + }, + }) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "request-1", + Payload: &gatewayv1.AgentEnvelope_ChatControl{ + ChatControl: &gatewayv1.ChatControlEvent{ + Type: "completed", + State: session.ChatRunStateCompleted, + RequestId: "request-1", + ConversationId: "conversation-1", + }, + }, + }) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "request-1", + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: "conversation-1", + Data: `{"text":"late token"}`, + }, + }, + }) + + ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) + if err != nil { + t.Fatalf("SubscribeChatRun: %v", err) + } + defer cleanup() + assertDoneOpen(t, done) + if snapshot.State != session.ChatRunStateFailed { + t.Fatalf("terminal state = %q, want %q", snapshot.State, session.ChatRunStateFailed) + } + + select { + case event := <-ch: + if event.Event == nil || event.Event.GetType() != gatewayv1.ChatEvent_ERROR { + t.Fatalf("replayed event = %#v, want ERROR", event) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for replayed error event") + } + select { + case event := <-ch: + t.Fatalf("terminal completion control should be ignored after failure: %#v", event) + default: + } +} + func TestDesktopBroadcastChatEventCreatesAttachableRun(t *testing.T) { t.Parallel() diff --git a/crates/agent-gateway/test/upload/import_readable_files_test.go b/crates/agent-gateway/test/upload/import_readable_files_test.go index 7b26d803a..d25c08480 100644 --- a/crates/agent-gateway/test/upload/import_readable_files_test.go +++ b/crates/agent-gateway/test/upload/import_readable_files_test.go @@ -65,7 +65,8 @@ func TestImportReadableFilesForwardsMultipartToAgent(t *testing.T) { var outbound *gatewayv1.GatewayEnvelope select { - case outbound = <-agentSession.Outbound(): + case delivered := <-agentSession.Outbound(): + outbound = delivered.GatewayEnvelope case <-time.After(time.Second): t.Fatalf("timed out waiting for upload request to reach agent") } diff --git a/crates/agent-gateway/test/websocket/chat_bridge_test.go b/crates/agent-gateway/test/websocket/chat_bridge_test.go index 194c9c0bc..f644ad2c3 100644 --- a/crates/agent-gateway/test/websocket/chat_bridge_test.go +++ b/crates/agent-gateway/test/websocket/chat_bridge_test.go @@ -77,6 +77,17 @@ func receiveEnvelopeWithID(t *testing.T, conn *websocket.Conn, id string) wsEnve return wsEnvelope{} } +func assertNoEnvelopeWithin(t *testing.T, conn *websocket.Conn, timeout time.Duration) { + t.Helper() + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { + t.Fatalf("set websocket deadline: %v", err) + } + var env wsEnvelope + if err := websocket.JSON.Receive(conn, &env); err == nil { + t.Fatalf("unexpected websocket envelope: %#v", env) + } +} + func authWebSocket(t *testing.T, conn *websocket.Conn, token string) { t.Helper() sendEnvelope(t, conn, "auth-1", "auth", map[string]any{"token": token}) @@ -90,13 +101,39 @@ func readOutboundEnvelope(t *testing.T, agentSession *session.AgentSession) *gat t.Helper() select { case outbound := <-agentSession.Outbound(): - return outbound + outbound.Ack(nil) + return outbound.GatewayEnvelope case <-time.After(time.Second): t.Fatalf("timed out waiting for gateway request to reach agent") return nil } } +func dispatchChatStarted(t *testing.T, sm *session.Manager, requestID string, conversationID string) { + t.Helper() + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: requestID, + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: conversationID, + Data: `{"type":"started"}`, + }, + }, + }) +} + +func markRuntimeReady(sm *session.Manager, agentSession *session.AgentSession) { + sm.UpdateRuntimeStatus(agentSession, &gatewayv1.RuntimeStatusEvent{ + WorkerId: "test-runtime", + State: "ready", + Visible: true, + ActiveRunCount: 0, + Timestamp: time.Now().Unix(), + }) +} + func TestWebSocketRejectsRequestsBeforeAuth(t *testing.T) { t.Parallel() @@ -121,6 +158,7 @@ func TestWebSocketChatStartForwardsNormalizedRequestAndStreamsEvents(t *testing. sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) handler := server.NewWebSocketServer(&config.Config{ Token: "ws-token", @@ -184,7 +222,9 @@ func TestWebSocketChatStartForwardsNormalizedRequestAndStreamsEvents(t *testing. var outbound *gatewayv1.GatewayEnvelope select { - case outbound = <-agentSession.Outbound(): + case delivered := <-agentSession.Outbound(): + delivered.Ack(nil) + outbound = delivered.GatewayEnvelope case <-time.After(time.Second): t.Fatalf("timed out waiting for chat request to reach agent") } @@ -278,6 +318,351 @@ func TestWebSocketChatStartForwardsNormalizedRequestAndStreamsEvents(t *testing. } } +func TestWebSocketChatStartRequiresRuntimeReady(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + + handler := server.NewWebSocketServer(&config.Config{ + Token: "ws-token", + RequestTimeout: time.Second, + }, sm) + conn, cleanup := dialGatewayWebSocket(t, handler) + defer cleanup() + + authWebSocket(t, conn, "ws-token") + sendEnvelope(t, conn, "chat-not-ready", "chat.start", map[string]any{ + "conversation_id": "conversation-not-ready", + "message": "hello gateway", + }) + + env := receiveEnvelope(t, conn) + if env.ID != "chat-not-ready" || + env.Type != "error" || + !strings.Contains(env.Error, "Desktop chat runtime is not ready") { + t.Fatalf("not-ready response = %#v, want chat runtime readiness error", env) + } + select { + case outbound := <-agentSession.Outbound(): + t.Fatalf("unexpected outbound request while runtime not ready: %#v", outbound) + case <-time.After(100 * time.Millisecond): + } + if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { + t.Fatalf("active chat runs after not-ready request = %#v, want empty", got) + } +} + +func TestWebSocketChatStartClearsRunWhenAgentDeliveryStalls(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) + + handler := server.NewWebSocketServer(&config.Config{ + Token: "ws-token", + RequestTimeout: time.Second, + WebSocketWriteTimeout: 50 * time.Millisecond, + }, sm) + conn, cleanup := dialGatewayWebSocket(t, handler) + defer cleanup() + + authWebSocket(t, conn, "ws-token") + sendEnvelope(t, conn, "chat-stalled", "chat.start", map[string]any{ + "conversation_id": "conversation-stalled", + "message": "hello gateway", + }) + + select { + case outbound := <-agentSession.Outbound(): + if outbound.GetChatRequest() == nil { + t.Fatalf("outbound payload = %T, want ChatRequest", outbound.GetPayload()) + } + case <-time.After(time.Second): + t.Fatalf("timed out waiting for chat request to be enqueued") + } + + env := receiveEnvelope(t, conn) + if env.ID != "chat-stalled" || env.Type != "error" || env.Error != "request timed out" { + t.Fatalf("stalled delivery response = %#v, want timeout error", env) + } + if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { + t.Fatalf("active chat runs after stalled delivery = %#v, want empty", got) + } +} + +func TestWebSocketChatStartClearsRunWhenDesktopDoesNotAccept(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) + + handler := server.NewWebSocketServer(&config.Config{ + Token: "ws-token", + RequestTimeout: time.Second, + WebSocketWriteTimeout: time.Second, + ChatStartTimeout: 50 * time.Millisecond, + }, sm) + conn, cleanup := dialGatewayWebSocket(t, handler) + defer cleanup() + + authWebSocket(t, conn, "ws-token") + sendEnvelope(t, conn, "chat-unaccepted", "chat.start", map[string]any{ + "conversation_id": "conversation-unaccepted", + "message": "hello gateway", + }) + + outbound := readOutboundEnvelope(t, agentSession) + if outbound.GetChatRequest() == nil { + t.Fatalf("outbound payload = %T, want ChatRequest", outbound.GetPayload()) + } + if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { + t.Fatalf("active chat runs before desktop accept = %#v, want empty", got) + } + + env := receiveEnvelope(t, conn) + if env.ID != "chat-unaccepted" || env.Type != "chat.event" { + t.Fatalf("unaccepted desktop response = %#v, want chat.event", env) + } + var payload map[string]any + if err := json.Unmarshal(env.Payload, &payload); err != nil { + t.Fatalf("decode unaccepted desktop payload: %v", err) + } + if payload["type"] != "error" || + payload["conversation_id"] != "conversation-unaccepted" || + !strings.Contains(fmt.Sprint(payload["message"]), "Desktop backend did not accept") { + t.Fatalf("unaccepted desktop payload = %#v", payload) + } + if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { + t.Fatalf("active chat runs after unaccepted desktop request = %#v, want empty", got) + } + if status := sm.Status(); status.Online { + t.Fatalf("status online = true after desktop failed to accept chat request") + } +} + +func TestWebSocketChatStartAcceptedByDesktopDoesNotTripStartTimeout(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) + + handler := server.NewWebSocketServer(&config.Config{ + Token: "ws-token", + RequestTimeout: time.Second, + WebSocketWriteTimeout: time.Second, + ChatStartTimeout: 50 * time.Millisecond, + }, sm) + conn, cleanup := dialGatewayWebSocket(t, handler) + defer cleanup() + + authWebSocket(t, conn, "ws-token") + sendEnvelope(t, conn, "chat-accepted", "chat.start", map[string]any{ + "conversation_id": "conversation-accepted", + "message": "hello gateway", + }) + + outbound := readOutboundEnvelope(t, agentSession) + if outbound.GetChatRequest() == nil { + t.Fatalf("outbound payload = %T, want ChatRequest", outbound.GetPayload()) + } + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: outbound.GetRequestId(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: "conversation-accepted", + Data: `{"type":"accepted"}`, + }, + }, + }) + + if got := sm.ActiveChatRunConversationIDs(); len(got) != 0 { + t.Fatalf("active chat runs after desktop accept before start = %#v, want empty", got) + } + assertNoEnvelopeWithin(t, conn, 120*time.Millisecond) +} + +func TestWebSocketChatStartFailsWhenDesktopAcceptsButDoesNotStart(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) + + handler := server.NewWebSocketServer(&config.Config{ + Token: "ws-token", + RequestTimeout: time.Second, + WebSocketWriteTimeout: time.Second, + ChatStartTimeout: 25 * time.Millisecond, + ChatRenderStartTimeout: 75 * time.Millisecond, + }, sm) + conn, cleanup := dialGatewayWebSocket(t, handler) + defer cleanup() + + authWebSocket(t, conn, "ws-token") + sendEnvelope(t, conn, "chat-render-stalled", "chat.start", map[string]any{ + "conversation_id": "conversation-render-stalled", + "message": "hello gateway", + }) + + outbound := readOutboundEnvelope(t, agentSession) + if outbound.GetChatRequest() == nil { + t.Fatalf("outbound payload = %T, want ChatRequest", outbound.GetPayload()) + } + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: outbound.GetRequestId(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: "conversation-render-stalled", + Data: `{"type":"accepted"}`, + }, + }, + }) + + env := receiveEnvelope(t, conn) + if env.ID != "chat-render-stalled" || env.Type != "chat.event" { + t.Fatalf("render-stalled response = %#v, want chat.event", env) + } + var payload map[string]any + if err := json.Unmarshal(env.Payload, &payload); err != nil { + t.Fatalf("decode render-stalled payload: %v", err) + } + if payload["type"] != "error" || + payload["conversation_id"] != "conversation-render-stalled" || + !strings.Contains(fmt.Sprint(payload["message"]), "Desktop app accepted") { + t.Fatalf("render-stalled payload = %#v", payload) + } +} + +func TestWebSocketChatResumeFailsPendingRunWhenDesktopStillDoesNotAccept(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) + + handler := server.NewWebSocketServer(&config.Config{ + Token: "ws-token", + RequestTimeout: time.Second, + WebSocketWriteTimeout: time.Second, + ChatStartTimeout: 50 * time.Millisecond, + }, sm) + conn1, cleanup1 := dialGatewayWebSocket(t, handler) + defer cleanup1() + + authWebSocket(t, conn1, "ws-token") + sendEnvelope(t, conn1, "chat-pending", "chat.start", map[string]any{ + "conversation_id": "conversation-pending", + "message": "hello gateway", + }) + outbound := readOutboundEnvelope(t, agentSession) + if outbound.GetChatRequest() == nil { + t.Fatalf("outbound payload = %T, want ChatRequest", outbound.GetPayload()) + } + _ = conn1.Close() + + conn2, cleanup2 := dialGatewayWebSocket(t, handler) + defer cleanup2() + authWebSocket(t, conn2, "ws-token") + sendEnvelope(t, conn2, "resume-pending", "chat.resume", map[string]any{ + "request_id": outbound.GetRequestId(), + "conversation_id": "conversation-pending", + }) + + env := receiveEnvelope(t, conn2) + if env.ID != outbound.GetRequestId() || env.Type != "chat.event" { + t.Fatalf("resume pending response = %#v, want chat.event", env) + } + var payload map[string]any + if err := json.Unmarshal(env.Payload, &payload); err != nil { + t.Fatalf("decode resume pending payload: %v", err) + } + if payload["type"] != "error" || !strings.Contains(fmt.Sprint(payload["message"]), "Desktop backend did not accept") { + t.Fatalf("resume pending payload = %#v", payload) + } + if status := sm.Status(); status.Online { + t.Fatalf("status online = true after resumed pending chat was not accepted") + } +} + +func TestWebSocketChatResumeFailsAcceptedRunWhenDesktopDoesNotStart(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) + + handler := server.NewWebSocketServer(&config.Config{ + Token: "ws-token", + RequestTimeout: time.Second, + WebSocketWriteTimeout: time.Second, + ChatStartTimeout: 25 * time.Millisecond, + ChatRenderStartTimeout: 75 * time.Millisecond, + }, sm) + conn1, cleanup1 := dialGatewayWebSocket(t, handler) + defer cleanup1() + + authWebSocket(t, conn1, "ws-token") + sendEnvelope(t, conn1, "chat-accepted-pending", "chat.start", map[string]any{ + "conversation_id": "conversation-accepted-pending", + "message": "hello gateway", + }) + outbound := readOutboundEnvelope(t, agentSession) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: outbound.GetRequestId(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: "conversation-accepted-pending", + Data: `{"type":"accepted"}`, + }, + }, + }) + _ = conn1.Close() + + conn2, cleanup2 := dialGatewayWebSocket(t, handler) + defer cleanup2() + authWebSocket(t, conn2, "ws-token") + sendEnvelope(t, conn2, "resume-accepted-pending", "chat.resume", map[string]any{ + "request_id": outbound.GetRequestId(), + "conversation_id": "conversation-accepted-pending", + }) + + env := receiveEnvelope(t, conn2) + if env.ID != outbound.GetRequestId() || env.Type != "chat.event" { + t.Fatalf("resume accepted pending response = %#v, want chat.event", env) + } + var payload map[string]any + if err := json.Unmarshal(env.Payload, &payload); err != nil { + t.Fatalf("decode resume accepted pending payload: %v", err) + } + if payload["type"] != "error" || !strings.Contains(fmt.Sprint(payload["message"]), "Desktop app accepted") { + t.Fatalf("resume accepted pending payload = %#v", payload) + } +} + func TestWebSocketChatStartDedupesClientRequestID(t *testing.T) { t.Parallel() @@ -285,6 +670,7 @@ func TestWebSocketChatStartDedupesClientRequestID(t *testing.T) { sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) handler := server.NewWebSocketServer(&config.Config{ Token: "ws-token", @@ -366,6 +752,7 @@ func TestWebSocketChatStartFailsWhenAgentSessionDisconnects(t *testing.T) { sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) handler := server.NewWebSocketServer(&config.Config{ Token: "ws-token", @@ -406,6 +793,7 @@ func TestWebSocketChatStartFailsWhenAgentSessionDisconnects(t *testing.T) { replacementSession := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(replacementSession) + markRuntimeReady(sm, replacementSession) sendEnvelope(t, conn, "chat-2", "chat.start", map[string]any{ "conversation_id": "conversation-1", "client_request_id": "client-submit-1", @@ -424,6 +812,7 @@ func TestWebSocketMemoryManageForwardsJSONArgs(t *testing.T) { sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) handler := server.NewWebSocketServer(&config.Config{ Token: "ws-token", @@ -487,6 +876,7 @@ func TestWebSocketChatResumeReplaysEventsAfterReconnect(t *testing.T) { sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) handler := server.NewWebSocketServer(&config.Config{ Token: "ws-token", @@ -596,6 +986,7 @@ func TestWebSocketChatAttachReplaysBufferedEventsByConversationID(t *testing.T) sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) handler := server.NewWebSocketServer(&config.Config{ Token: "ws-token", @@ -686,6 +1077,7 @@ func TestWebSocketChatAttachExpiresAfterDoneHistoryUpsert(t *testing.T) { sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) handler := server.NewWebSocketServer(&config.Config{ Token: "ws-token", @@ -751,6 +1143,7 @@ func TestWebSocketChatCancelReleasesBufferedAttachRun(t *testing.T) { sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) handler := server.NewWebSocketServer(&config.Config{ Token: "ws-token", @@ -810,6 +1203,7 @@ func TestWebSocketForwardsHistorySettingsAndFsRPCs(t *testing.T) { sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) handler := server.NewWebSocketServer(&config.Config{ Token: "ws-token", @@ -828,6 +1222,7 @@ func TestWebSocketForwardsHistorySettingsAndFsRPCs(t *testing.T) { if chatOutbound.GetChatRequest() == nil { t.Fatalf("chat outbound payload = %T, want ChatRequest", chatOutbound.GetPayload()) } + dispatchChatStarted(t, sm, chatOutbound.GetRequestId(), "conversation-1") sendEnvelope(t, conn, "history-1", "history.list", map[string]any{ "page": 2, @@ -1275,6 +1670,7 @@ func TestWebSocketDefaultsInvalidHistoryListPagination(t *testing.T) { sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(agentSession) + markRuntimeReady(sm, agentSession) handler := server.NewWebSocketServer(&config.Config{ Token: "ws-token", diff --git a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs index 43e511971..7905c433e 100644 --- a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs +++ b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs @@ -70,12 +70,44 @@ function installBrowser(options = {}) { FakeWebSocket.instances = []; globalThis.WebSocket = FakeWebSocket; delete globalThis.SharedWorker; + const windowListeners = new Map(); + const documentListeners = new Map(); + const addListener = (listeners, type, listener) => { + const items = listeners.get(type) ?? new Set(); + items.add(listener); + listeners.set(type, items); + }; + const removeListener = (listeners, type, listener) => { + listeners.get(type)?.delete(listener); + }; + const dispatch = (listeners, event) => { + const type = event?.type; + if (typeof type !== "string") return; + for (const listener of listeners.get(type) ?? []) { + listener(event); + } + }; globalThis.window = { location: { origin: "https://gateway.example" }, setTimeout: options.setTimeout ?? setTimeout, clearTimeout: options.clearTimeout ?? clearTimeout, setInterval: options.setInterval ?? setInterval, clearInterval: options.clearInterval ?? clearInterval, + addEventListener: (type, listener) => addListener(windowListeners, type, listener), + removeEventListener: (type, listener) => removeListener(windowListeners, type, listener), + dispatchEvent: (event) => { + dispatch(windowListeners, event); + return true; + }, + }; + globalThis.document = { + visibilityState: options.visibilityState ?? "visible", + addEventListener: (type, listener) => addListener(documentListeners, type, listener), + removeEventListener: (type, listener) => removeListener(documentListeners, type, listener), + dispatchEvent: (event) => { + dispatch(documentListeners, event); + return true; + }, }; } @@ -216,6 +248,35 @@ test("SharedWorker gateway client sends conversation cancel even without a local resetGatewayWebSocketClient(); }); +test("SharedWorker gateway client forwards foreground wakeups to the worker", async () => { + installBrowser(); + FakeSharedWorker.instances = []; + globalThis.SharedWorker = FakeSharedWorker; + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + resetGatewayWebSocketClient(); + + getGatewayWebSocketClient(" token "); + assert.equal(FakeSharedWorker.instances.length, 1); + const port = FakeSharedWorker.instances[0].port; + const connect = port.messages.find((message) => message.type === "connect"); + assert.ok(connect); + port.emit({ + type: "ready", + connection_id: connect.connection_id, + payload: { status: { online: true }, error: null }, + }); + + window.dispatchEvent({ type: "pageshow" }); + + assert.deepEqual(port.messages.at(-1), { + type: "wakeup", + connection_id: connect.connection_id, + }); + + resetGatewayWebSocketClient(); +}); + test("SharedWorker gateway client accepts terminal list sessions from worker payload", async () => { installBrowser(); FakeSharedWorker.instances = []; @@ -468,6 +529,69 @@ test("Gateway SharedWorker broadcasts events with each port connection id", asyn globalThis.onconnect = previousOnConnect; }); +test("Gateway SharedWorker applies foreground wakeups to the managed socket client", async () => { + installBrowser(); + const loader = createWebModuleLoader(); + const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); + const clientInstances = []; + + class MockGatewayWebSocketClient { + wakeups = 0; + + constructor(token) { + this.token = token; + clientInstances.push(this); + } + + subscribeStatus() { + return () => {}; + } + + subscribeHistory() { + return () => {}; + } + + subscribeConversation() { + return () => {}; + } + + subscribeSettings() { + return () => {}; + } + + subscribeTerminal() { + return () => {}; + } + + noteForegroundWakeup() { + this.wakeups += 1; + } + + dispose() {} + } + + const workerLoader = createWebModuleLoader({ + mocks: { + [gatewaySocketPath]: { + GatewayWebSocketClient: MockGatewayWebSocketClient, + }, + }, + }); + + const previousOnConnect = globalThis.onconnect; + workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); + + const port = new FakeMessagePort(); + globalThis.onconnect({ ports: [port] }); + port.emit({ type: "connect", connection_id: "connection-1", token: "token" }); + port.emit({ type: "wakeup", connection_id: "connection-1" }); + + assert.equal(clientInstances.length, 1); + assert.equal(clientInstances[0].wakeups, 1); + + globalThis.onconnect = previousOnConnect; +}); + test("Gateway SharedWorker terminal metadata reaches every page while output stays scoped", async () => { installBrowser(); const loader = createWebModuleLoader(); @@ -1618,6 +1742,208 @@ test("Gateway SharedWorker forwards history share requests", async () => { globalThis.onconnect = previousOnConnect; }); +test("Gateway SharedWorker forwards tunnel requests", async () => { + installBrowser(); + const loader = createWebModuleLoader(); + const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); + const clientInstances = []; + + class MockGatewayWebSocketClient { + calls = []; + + constructor(token) { + this.token = token; + clientInstances.push(this); + } + + subscribeStatus() { + return () => {}; + } + + subscribeHistory() { + return () => {}; + } + + subscribeConversation() { + return () => {}; + } + + subscribeSettings() { + return () => {}; + } + + subscribeTerminal() { + return () => {}; + } + + listTunnels() { + this.calls.push(["listTunnels"]); + return [ + { + id: "tun-1", + slug: "slug-1", + name: "App", + targetUrl: "http://localhost:3000", + publicUrl: "https://gateway.example/t/slug-1/", + createdAt: 10, + expiresAt: 3700, + activeConnections: 0, + status: "active", + }, + ]; + } + + createTunnel(input) { + this.calls.push(["createTunnel", input]); + return { + id: "tun-2", + slug: "slug-2", + name: input.name ?? "", + targetUrl: input.targetUrl, + publicUrl: "https://gateway.example/t/slug-2/", + createdAt: 20, + expiresAt: 920, + activeConnections: 0, + status: "active", + }; + } + + updateTunnel(input) { + this.calls.push(["updateTunnel", input]); + return { + id: input.id, + slug: "slug-2", + name: input.name ?? "", + targetUrl: input.targetUrl, + publicUrl: "https://gateway.example/t/slug-2/", + createdAt: 20, + expiresAt: input.ttlSeconds === 0 ? 0 : 920, + activeConnections: 0, + status: "active", + projectPathKey: input.projectPathKey ?? "", + }; + } + + closeTunnel(id) { + this.calls.push(["closeTunnel", id]); + return { + id, + slug: "slug-2", + name: "Closed", + targetUrl: "http://localhost:3000", + publicUrl: "https://gateway.example/t/slug-2/", + createdAt: 20, + expiresAt: 920, + activeConnections: 0, + status: "expired", + }; + } + + dispose() {} + } + + const workerLoader = createWebModuleLoader({ + mocks: { + [gatewaySocketPath]: { + GatewayWebSocketClient: MockGatewayWebSocketClient, + }, + }, + }); + + const previousOnConnect = globalThis.onconnect; + workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); + + const port = new FakeMessagePort(); + globalThis.onconnect({ ports: [port] }); + port.emit({ type: "connect", connection_id: "connection-1", token: " token " }); + assert.equal(clientInstances.length, 1); + + port.emit({ + type: "request", + connection_id: "connection-1", + request_id: "tunnel-list", + method: "tunnel.list", + payload: {}, + }); + await waitFor( + () => port.messages.some((message) => message.request_id === "tunnel-list"), + "shared worker tunnel list response", + ); + assert.deepEqual(clientInstances[0].calls.at(-1), ["listTunnels"]); + assert.equal(port.messages.at(-1).payload.tunnels[0].id, "tun-1"); + + port.emit({ + type: "request", + connection_id: "connection-1", + request_id: "tunnel-create", + method: "tunnel.create", + payload: { + targetUrl: "http://localhost:3000/app", + ttlSeconds: 900, + name: "App", + }, + }); + await waitFor( + () => port.messages.some((message) => message.request_id === "tunnel-create"), + "shared worker tunnel create response", + ); + assert.deepEqual(clientInstances[0].calls.at(-1), [ + "createTunnel", + { + targetUrl: "http://localhost:3000/app", + ttlSeconds: 900, + name: "App", + }, + ]); + assert.equal(port.messages.at(-1).payload.tunnel.id, "tun-2"); + + port.emit({ + type: "request", + connection_id: "connection-1", + request_id: "tunnel-update-infinite", + method: "tunnel.update", + payload: { + id: "tun-2", + targetUrl: "http://localhost:4000/dashboard", + ttlSeconds: 0, + name: "Dashboard", + projectPathKey: "project:/tmp/liveagent", + }, + }); + await waitFor( + () => port.messages.some((message) => message.request_id === "tunnel-update-infinite"), + "shared worker tunnel update response", + ); + assert.deepEqual(clientInstances[0].calls.at(-1), [ + "updateTunnel", + { + id: "tun-2", + targetUrl: "http://localhost:4000/dashboard", + ttlSeconds: 0, + name: "Dashboard", + projectPathKey: "project:/tmp/liveagent", + }, + ]); + assert.equal(port.messages.at(-1).payload.tunnel.expiresAt, 0); + assert.equal(port.messages.at(-1).payload.tunnel.projectPathKey, "project:/tmp/liveagent"); + + port.emit({ + type: "request", + connection_id: "connection-1", + request_id: "tunnel-close", + method: "tunnel.close", + payload: { id: "tun-2" }, + }); + await waitFor( + () => port.messages.some((message) => message.request_id === "tunnel-close"), + "shared worker tunnel close response", + ); + assert.deepEqual(clientInstances[0].calls.at(-1), ["closeTunnel", "tun-2"]); + assert.equal(port.messages.at(-1).payload.tunnel.status, "expired"); + + globalThis.onconnect = previousOnConnect; +}); + test("Gateway SharedWorker forwards chat.attach streams to the requesting port", async () => { installBrowser(); const loader = createWebModuleLoader(); @@ -2039,6 +2365,78 @@ test("GatewayWebSocketClient reconnects before read requests when an authenticat } }); +test("GatewayWebSocketClient reconnects before chat.start after a foreground restore", async () => { + installBrowser(); + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + resetGatewayWebSocketClient(); + + const realDateNow = Date.now; + try { + const client = getGatewayWebSocketClient("token"); + const statusPromise = client.getStatus(); + const firstSocket = await connectAndAuth(); + await waitFor(() => firstSocket.sent.some((item) => item.type === "status.get"), "initial status.get"); + const statusRequest = firstSocket.sent.find((item) => item.type === "status.get"); + firstSocket.receive({ + id: statusRequest.id, + type: "response", + payload: { online: true, agent_id: "desktop-agent" }, + }); + await statusPromise; + + let mockNow = realDateNow(); + Date.now = () => mockNow; + mockNow += 12_000; + window.dispatchEvent({ type: "pageshow" }); + + const stream = client.chat("hello", "conversation-1"); + const firstEventPromise = stream.next(); + assert.equal(firstSocket.readyState, FakeWebSocket.CLOSED); + assert.equal(FakeWebSocket.instances.length, 2); + + Date.now = realDateNow; + + const reconnectSocket = FakeWebSocket.instances[1]; + reconnectSocket.open(); + await waitFor(() => reconnectSocket.sent.length >= 1, "foreground reconnect auth envelope"); + reconnectSocket.receive({ + id: reconnectSocket.sent[0].id, + type: "response", + payload: { ok: true }, + }); + await waitFor( + () => reconnectSocket.sent.some((item) => item.type === "chat.start"), + "chat.start after foreground reconnect", + ); + const chatStart = reconnectSocket.sent.find((item) => item.type === "chat.start"); + assert.deepEqual(chatStart.payload.conversation_id, "conversation-1"); + + reconnectSocket.receive({ + id: chatStart.id, + type: "chat.control", + payload: { + type: "started", + state: "running", + conversation_id: "conversation-1", + seq: 1, + }, + }); + assert.deepEqual(await firstEventPromise, { + value: { + type: "started", + state: "running", + conversation_id: "conversation-1", + seq: 1, + }, + done: false, + }); + } finally { + Date.now = realDateNow; + resetGatewayWebSocketClient(); + } +}); + test("GatewayWebSocketClient retries history.get after a recoverable transport stall timeout", async () => { const realSetTimeout = setTimeout; installBrowser({ @@ -2089,6 +2487,61 @@ test("GatewayWebSocketClient retries history.get after a recoverable transport s resetGatewayWebSocketClient(); }); +test("GatewayWebSocketClient recovers chat.start when the socket stops receiving inbound traffic", async () => { + const realSetTimeout = setTimeout; + installBrowser({ + setTimeout: (fn, delay, ...args) => + realSetTimeout(fn, delay >= 8_000 ? 0 : delay, ...args), + }); + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + resetGatewayWebSocketClient(); + + const client = getGatewayWebSocketClient("token"); + const stream = client.chat("hello", "conversation-1"); + const firstEventPromise = stream.next(); + const firstSocket = await connectAndAuth(0); + await waitFor(() => firstSocket.sent.some((item) => item.type === "chat.start"), "chat.start envelope"); + const chatStart = firstSocket.sent.find((item) => item.type === "chat.start"); + + await waitFor(() => FakeWebSocket.instances.length === 2, "chat.start transport recovery websocket"); + const reconnectSocket = FakeWebSocket.instances[1]; + reconnectSocket.open(); + await waitFor(() => reconnectSocket.sent.length >= 1, "chat.start recovery auth envelope"); + reconnectSocket.receive({ + id: reconnectSocket.sent[0].id, + type: "response", + payload: { ok: true }, + }); + + await waitFor( + () => reconnectSocket.sent.some((item) => item.type === "chat.resume"), + "chat.start recovery resume envelope", + ); + const resume = reconnectSocket.sent.find((item) => item.type === "chat.resume"); + assert.deepEqual(resume.payload, { + request_id: chatStart.id, + conversation_id: "conversation-1", + after_seq: 0, + }); + + reconnectSocket.receive({ + id: chatStart.id, + type: "error", + error: "chat run not found", + }); + assert.deepEqual(await firstEventPromise, { + value: { + type: "error", + message: "chat run not found", + conversation_id: "conversation-1", + }, + done: false, + }); + assert.deepEqual(await stream.next(), { value: undefined, done: true }); + resetGatewayWebSocketClient(); +}); + test("GatewayWebSocketClient does not open chat streams for pre-aborted signals", async () => { installBrowser(); const loader = createWebModuleLoader(); diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index 5f28d7086..60708b2e0 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -198,6 +198,10 @@ test("loadWebSettings forces current gateway URL/token over stale persisted remo openProjectPathKeys: ["/stale/project"], openVersion: 1, }; + stale.customSettings.projectToolsTunnel = { + openProjectPathKeys: ["/stale/project"], + openVersion: 1, + }; store.set("liveagent.gateway.webui.settings.v1", JSON.stringify(stale)); const loaded = webSettings.loadWebSettings(" new-token "); @@ -207,6 +211,7 @@ test("loadWebSettings forces current gateway URL/token over stale persisted remo assert.equal(loaded.remote.enabled, true); assert.deepEqual(loaded.customSettings.projectToolsFileTree.openProjectPathKeys, []); assert.deepEqual(loaded.customSettings.projectToolsGitReview.openProjectPathKeys, []); + assert.deepEqual(loaded.customSettings.projectToolsTunnel.openProjectPathKeys, []); }); test("gateway settings sync keeps remote connection local and syncs web terminal setting", () => { @@ -249,6 +254,7 @@ test("gateway settings sync keeps remote connection local and syncs web terminal assert.deepEqual(payload.remote, { enableWebTerminal: synced.remote.enableWebTerminal, enableWebGit: synced.remote.enableWebGit, + enableWebTunnels: synced.remote.enableWebTunnels, }); assert.deepEqual(payload.chatRuntimeControls, synced.chatRuntimeControls); }); @@ -338,48 +344,116 @@ test("gateway settings sync preserves active workspace project by path when ids assert.equal(synced.system.activeWorkspaceProjectId, "desktop-project-a"); }); -test("gateway settings sync keeps newer git review tab open state", () => { +test("gateway settings sync keeps newer project tool tab open state", () => { installWindow(); const current = settings.normalizeSettings({ customSettings: { + projectToolsPanel: { + width: 612, + activeTab: "gitReview", + activeTabs: { + "/web/project": "gitReview", + }, + tabOrders: { + "/web/project": ["__git_review__", "__file_tree__"], + }, + }, projectToolsGitReview: { openProjectPathKeys: ["/web/project"], openVersion: 2, }, + projectToolsTunnel: { + openProjectPathKeys: ["/web/project"], + openVersion: 2, + }, }, }); const staleSynced = settingsSync.applyGatewaySettingsSyncPayload(current, { customSettings: { + projectToolsPanel: { + width: 360, + activeTab: "terminal", + tabOrders: { + "/desktop/project": ["terminal-1", "__file_tree__"], + }, + }, projectToolsGitReview: { openProjectPathKeys: [], openVersion: 1, }, + projectToolsTunnel: { + openProjectPathKeys: [], + openVersion: 1, + }, }, }); assert.deepEqual(staleSynced.customSettings.projectToolsGitReview.openProjectPathKeys, [ "/web/project", ]); assert.equal(staleSynced.customSettings.projectToolsGitReview.openVersion, 2); + assert.deepEqual(staleSynced.customSettings.projectToolsTunnel.openProjectPathKeys, [ + "/web/project", + ]); + assert.equal(staleSynced.customSettings.projectToolsTunnel.openVersion, 2); + assert.equal(staleSynced.customSettings.projectToolsPanel.width, 612); + assert.equal(staleSynced.customSettings.projectToolsPanel.activeTab, "gitReview"); + assert.deepEqual(staleSynced.customSettings.projectToolsPanel.activeTabs, { + "/web/project": "gitReview", + }); + assert.deepEqual(staleSynced.customSettings.projectToolsPanel.tabOrders, { + "/web/project": ["__git_review__", "__file_tree__"], + }); const newerSynced = settingsSync.applyGatewaySettingsSyncPayload(staleSynced, { customSettings: { + projectToolsPanel: { + width: 360, + activeTab: "tunnel", + activeTabs: { + "/desktop/project": "tunnel", + }, + tabOrders: { + "/desktop/project": ["terminal-1", "__tunnel__"], + }, + }, projectToolsGitReview: { openProjectPathKeys: ["/desktop/project"], openVersion: 3, }, + projectToolsTunnel: { + openProjectPathKeys: ["/desktop/project"], + openVersion: 3, + }, }, }); assert.deepEqual(newerSynced.customSettings.projectToolsGitReview.openProjectPathKeys, [ "/desktop/project", ]); assert.equal(newerSynced.customSettings.projectToolsGitReview.openVersion, 3); + assert.deepEqual(newerSynced.customSettings.projectToolsTunnel.openProjectPathKeys, [ + "/desktop/project", + ]); + assert.equal(newerSynced.customSettings.projectToolsTunnel.openVersion, 3); + assert.equal(newerSynced.customSettings.projectToolsPanel.width, 612); + assert.equal(newerSynced.customSettings.projectToolsPanel.activeTab, "gitReview"); + assert.deepEqual(newerSynced.customSettings.projectToolsPanel.activeTabs, { + "/web/project": "gitReview", + }); + assert.deepEqual(newerSynced.customSettings.projectToolsPanel.tabOrders, { + "/web/project": ["__git_review__", "__file_tree__"], + }); const payload = settingsSync.buildGatewaySettingsSyncPayload(newerSynced); + assert.equal(Object.hasOwn(payload.customSettings, "projectToolsPanel"), false); assert.deepEqual(payload.customSettings.projectToolsGitReview, { openProjectPathKeys: ["/desktop/project"], openVersion: 3, }); + assert.deepEqual(payload.customSettings.projectToolsTunnel, { + openProjectPathKeys: ["/desktop/project"], + openVersion: 3, + }); }); test("gateway settings sync keeps newer project conversation activity", () => { diff --git a/crates/agent-gateway/web/src/App.tsx b/crates/agent-gateway/web/src/App.tsx index ba683e931..973d607b5 100644 --- a/crates/agent-gateway/web/src/App.tsx +++ b/crates/agent-gateway/web/src/App.tsx @@ -59,16 +59,18 @@ import { McpHubPage } from "@/pages/mcp-hub/McpHubPage"; import type { SectionId } from "@/pages/settings/types"; import { useChatSkills } from "@/pages/chat/useChatSkills"; import { mergeAlwaysEnabledSkillNames } from "@/lib/skills"; -import { buildModelOptions, sortHistoryItems } from "@/lib/chat/chatPageHelpers"; +import { buildModelOptions, sortHistoryItems, VIBING_STATUS } from "@/lib/chat/chatPageHelpers"; import { SettingsPage } from "@/pages/SettingsPage"; import { findProviderModelConfig, getChatRuntimeReasoningLevelsForProvider, getProjectToolsFileTreeProjectState, + getProjectToolsPanelActiveTab, getProjectToolsPanelTabOrder, isAgentDevMode, isProjectToolsFileTreeOpen, isProjectToolsGitReviewOpen, + isProjectToolsTunnelOpen, normalizeChatRuntimeControlsForProvider, normalizeSettings, removeProjectToolsProjectState, @@ -79,6 +81,8 @@ import { updateProjectToolsFileTreeProjectState, updateProjectToolsFileTreeOpen, updateProjectToolsGitReviewOpen, + updateProjectToolsTunnelOpen, + updateProjectToolsPanelActiveTab, updateProjectToolsPanelTabOrder, type AppSettings, type ChatRuntimeControls, @@ -107,6 +111,7 @@ import { import type { TerminalSession } from "./lib/terminal/types"; import type { AgentStatus, + ChatControlEvent, ChatEvent, ConversationSummary, GatewayHistoryEvent, @@ -379,6 +384,8 @@ const SECONDS_TIMESTAMP_MAX = 10_000_000_000; const DRAFT_HISTORY_ADOPTION_WINDOW_MS = 30_000; const LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS = 30_000; const PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS = 900; +const CHAT_RUNTIME_PREPARE_TIMEOUT_MS = 2_500; +const CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS = 1_500; const DEFAULT_BROWSER_TITLE = "LiveAgent Gateway"; const NEW_CONVERSATION_BROWSER_TITLE = "LiveAgent"; const SHARED_HISTORY_BROWSER_TITLE = "分享会话"; @@ -477,6 +484,66 @@ function isTerminalChatEvent(event: ChatEvent) { return event.type === "done" || event.type === "error"; } +function isChatControlEvent(event: ChatEvent): event is ChatControlEvent { + switch (event.type) { + case "accepted": + case "delivered": + case "claimed": + case "starting": + case "started": + case "progress": + case "completed": + case "failed": + case "cancelled": + return true; + default: + return false; + } +} + +function isTerminalChatControlEvent(event: ChatEvent) { + return ( + isChatControlEvent(event) && + (event.state === "completed" || event.state === "failed" || event.state === "cancelled") + ); +} + +function isRunningChatControlEvent(event: ChatEvent) { + return isChatControlEvent(event) && (event.state === "running" || event.type === "started"); +} + +type TunnelManagerToolChange = { + action: "create" | "close"; + projectPathKey: string; +}; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function readTunnelManagerToolChange(event: ChatEvent): TunnelManagerToolChange | null { + if (event.type !== "tool_result" || event.isError === true) { + return null; + } + const details = asRecord(event.details); + if (details.kind !== "tunnel_manager") { + return null; + } + const action = typeof details.action === "string" ? details.action.trim() : ""; + if (action !== "create" && action !== "close") { + return null; + } + const tunnel = asRecord(details.tunnel); + const projectPathKey = + (typeof tunnel.projectPathKey === "string" ? tunnel.projectPathKey.trim() : "") || + (typeof tunnel.project_path_key === "string" ? tunnel.project_path_key.trim() : "") || + event.workdir?.trim() || + ""; + return { action, projectPathKey }; +} + function buildGatewaySelectedModel( selectedModel: SelectedModel | undefined, providers: ModelProviderSource[], @@ -829,6 +896,7 @@ export default function App() { const [isFileDropActive, setIsFileDropActive] = useState(false); const [activeView, setActiveView] = useState<"chat" | "skills-hub" | "mcp-hub">("chat"); const [projectToolsPanelOpen, setProjectToolsPanelOpen] = useState(false); + const [tunnelRefreshToken, setTunnelRefreshToken] = useState(0); const previousProjectToolsFileTreeOpenRef = useRef(false); const [workspaceEditorMounted, setWorkspaceEditorMounted] = useState(false); const [workspaceEditorOpen, setWorkspaceEditorOpen] = useState(false); @@ -861,6 +929,7 @@ export default function App() { const fileInputRef = useRef(null); const conversationIdRef = useRef(conversationId); const selectedHistoryIdRef = useRef(selectedHistoryId); + const statusRef = useRef(status); const chatBusyRef = useRef(chatBusy); const chatMessagesRef = useRef(chatMessages); const chatErrorRef = useRef(chatError); @@ -878,6 +947,7 @@ export default function App() { const sharedHistoryItemsRef = useRef([]); const sharedHistoryListRequestRef = useRef | null>(null); const pendingUploadedFilesRef = useRef(pendingUploadedFiles); + const pendingUploadsByConversationRef = useRef>(new Map()); const isUploadingFilesRef = useRef(isUploadingFiles); const uploadDragDepthRef = useRef(0); const localRunningConversationIdsRef = useRef>(new Set()); @@ -909,6 +979,9 @@ export default function App() { const draftConversationPinnedRef = useRef(false); const protectedConversationRef = useRef(""); const chatStartLocksRef = useRef>(new Set()); + const chatPreflightInFlightRef = useRef(false); + const chatStartInFlightRef = useRef(false); + const chatRuntimePreparePromiseRef = useRef | null>(null); const submitInFlightRef = useRef(false); const pendingDraftConversationMigrationRef = useRef( null, @@ -1133,6 +1206,10 @@ export default function App() { selectedHistoryIdRef.current = selectedHistoryId; }, [selectedHistoryId]); + useEffect(() => { + statusRef.current = status; + }, [status]); + useEffect(() => { chatBusyRef.current = chatBusy; }, [chatBusy]); @@ -1189,10 +1266,72 @@ export default function App() { pendingUploadedFilesRef.current = pendingUploadedFiles; }, [pendingUploadedFiles]); + useEffect(() => { + const displayedConversationId = resolveVisibleConversationId( + selectedHistoryId, + conversationId, + ).trim(); + const nextFiles = displayedConversationId + ? (pendingUploadsByConversationRef.current.get(displayedConversationId) ?? []) + : []; + pendingUploadedFilesRef.current = nextFiles; + setPendingUploadedFiles(nextFiles); + }, [conversationId, selectedHistoryId]); + useEffect(() => { isUploadingFilesRef.current = isUploadingFiles; }, [isUploadingFiles]); + function getDisplayedConversationId() { + return resolveVisibleConversationId( + selectedHistoryIdRef.current, + conversationIdRef.current, + ).trim(); + } + + function isDisplayedConversation(targetConversationId: string) { + const conversationIdValue = targetConversationId.trim(); + return conversationIdValue !== "" && getDisplayedConversationId() === conversationIdValue; + } + + function getPendingUploadsForConversation(targetConversationId: string) { + const conversationIdValue = targetConversationId.trim(); + if (!conversationIdValue || isDisplayedConversation(conversationIdValue)) { + return pendingUploadedFilesRef.current; + } + return pendingUploadsByConversationRef.current.get(conversationIdValue) ?? []; + } + + function setPendingUploadsForConversation( + targetConversationId: string, + nextFiles: PendingUploadedFile[], + ) { + const conversationIdValue = targetConversationId.trim(); + const normalizedFiles = nextFiles.slice(); + if (conversationIdValue) { + if (normalizedFiles.length > 0) { + pendingUploadsByConversationRef.current.set(conversationIdValue, normalizedFiles); + } else { + pendingUploadsByConversationRef.current.delete(conversationIdValue); + } + } + if (!conversationIdValue || isDisplayedConversation(conversationIdValue)) { + pendingUploadedFilesRef.current = normalizedFiles; + setPendingUploadedFiles(normalizedFiles); + } + } + + function updatePendingUploadsForConversation( + targetConversationId: string, + updater: (current: PendingUploadedFile[]) => PendingUploadedFile[], + ) { + const conversationIdValue = targetConversationId.trim(); + const currentFiles = getPendingUploadsForConversation(conversationIdValue); + const nextFiles = updater(currentFiles); + setPendingUploadsForConversation(conversationIdValue, nextFiles); + return nextFiles; + } + useEffect(() => { localRunningConversationIdsRef.current = localRunningConversationIds; }, [localRunningConversationIds]); @@ -2035,6 +2174,37 @@ export default function App() { [queueSettingsSave], ); + const openTunnelToolPanel = useCallback( + (projectPathKey?: string) => { + const targetProjectPathKey = + workspaceProjectPathKey(projectPathKey) || + workspaceProjectPathKey(activeWorkspaceProjectPath); + if (!targetProjectPathKey) return; + setActiveView("chat"); + setProjectToolsPanelOpen(true); + setSettings((prev) => + updateProjectToolsTunnelOpen( + updateProjectToolsPanelActiveTab(prev, targetProjectPathKey, "tunnel"), + targetProjectPathKey, + true, + ), + ); + }, + [activeWorkspaceProjectPath, setSettings], + ); + + const handleTunnelManagerChatEvent = useCallback( + (event: ChatEvent) => { + const change = readTunnelManagerToolChange(event); + if (!change) return; + setTunnelRefreshToken((current) => current + 1); + if (change.action === "create") { + openTunnelToolPanel(change.projectPathKey); + } + }, + [openTunnelToolPanel], + ); + const persistProjectConversationActivity = useCallback( (activity: ReadonlyMap) => { if (activity.size === 0) { @@ -2248,12 +2418,7 @@ export default function App() { activateWorkspaceProject(project); setSettings((prev) => updateProjectToolsFileTreeOpen( - updateCustomSettings(prev, { - projectToolsPanel: { - ...prev.customSettings.projectToolsPanel, - activeTab: "fileTree", - }, - }), + updateProjectToolsPanelActiveTab(prev, pathKey, "fileTree"), pathKey, true, ), @@ -2828,6 +2993,7 @@ export default function App() { liveStore.appendEvent(event, { flush: event.type === "done" || event.type === "error", }); + handleTunnelManagerChatEvent(event); if (event.type === "done" || event.type === "error") { terminalEventSeen = true; @@ -2884,6 +3050,7 @@ export default function App() { commitTerminalConversationLiveStream, getConversationAbortController, getConversationLiveStreamStore, + handleTunnelManagerChatEvent, markCompletedLiveStream, markLiveConversationStreamActive, recoverUnavailableConversationStream, @@ -3090,6 +3257,7 @@ export default function App() { clearConversationLiveStream, getConversationAbortController, getHistoryPositionLockedConversationIds, + handleTunnelManagerChatEvent, hasRecentlyCompletedLiveStream, hasRetainedConversationLiveStream, isAgentMode, @@ -3122,14 +3290,35 @@ export default function App() { return; } + const visibleBroadcastConversationId = resolveVisibleConversationId( + selectedHistoryIdRef.current, + conversationIdRef.current, + ); + if (isChatControlEvent(event)) { + if (isRunningChatControlEvent(event)) { + setRemoteConversationRunningState(targetConversationId, true, { + workdir: event.workdir, + }); + if ( + visibleBroadcastConversationId === targetConversationId && + !isConversationLiveStreamAttached(targetConversationId) + ) { + attachVisibleConversationLiveStream(targetConversationId, api); + } + } else if (isTerminalChatControlEvent(event)) { + setRemoteConversationRunningState(targetConversationId, false, { + workdir: event.workdir, + }); + } + return; + } const isTerminalEvent = isTerminalChatEvent(event); if (!isTerminalEvent && !isChatStreamNotAvailableEvent(event)) { setRemoteConversationRunningState(targetConversationId, true, { workdir: event.workdir, }); if ( - resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === - targetConversationId && + visibleBroadcastConversationId === targetConversationId && !isConversationLiveStreamAttached(targetConversationId) ) { attachVisibleConversationLiveStream(targetConversationId, api); @@ -3177,6 +3366,9 @@ export default function App() { liveStore.appendEvent(event, { flush: isTerminalEvent, }); + if (visibleBroadcastConversationId === targetConversationId) { + handleTunnelManagerChatEvent(event); + } if (isTerminalEvent) { markCompletedLiveStream(targetConversationId); commitTerminalConversationLiveStream(targetConversationId); @@ -3197,6 +3389,7 @@ export default function App() { commitTerminalConversationLiveStream, getConversationAbortController, getConversationLiveStreamStore, + handleTunnelManagerChatEvent, isConversationLiveStreamAttached, markCompletedLiveStream, markLiveConversationStreamActive, @@ -3825,12 +4018,77 @@ export default function App() { ], ); + const prepareChatRuntime = useCallback( + async ( + reason: string, + currentApi = api, + timeoutMs = CHAT_RUNTIME_PREPARE_TIMEOUT_MS, + ): Promise => { + if (!currentApi) { + throw new Error("Gateway client is not ready."); + } + + if (!chatRuntimePreparePromiseRef.current) { + chatPreflightInFlightRef.current = true; + chatRuntimePreparePromiseRef.current = currentApi + .prepareChatRuntime(reason) + .then((nextStatus) => { + statusRef.current = nextStatus; + setStatus(nextStatus); + setStatusError(null); + return nextStatus; + }) + .catch((error) => { + setStatusError(asErrorMessage(error, "status request failed")); + throw error; + }) + .finally(() => { + chatRuntimePreparePromiseRef.current = null; + chatPreflightInFlightRef.current = false; + }); + } + + const preparePromise = chatRuntimePreparePromiseRef.current; + if (!preparePromise) { + throw new Error("Gateway chat runtime preparation did not start."); + } + if (timeoutMs <= 0) { + return preparePromise; + } + + let timeoutId: number | null = null; + try { + return await Promise.race([ + preparePromise, + new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + reject(new Error("Desktop chat runtime is recovering. Please retry shortly.")); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + } + }, + [api], + ); + const recoverVisibleConversationAfterPageRestore = useCallback( (currentApi = api) => { if (!currentApi) { return; } + if ( + chatPreflightInFlightRef.current || + chatStartInFlightRef.current || + submitInFlightRef.current + ) { + return; + } + const visibleConversationId = resolveVisibleConversationId( selectedHistoryIdRef.current, conversationIdRef.current, @@ -3906,6 +4164,9 @@ export default function App() { if (!api || !status?.online) { return; } + if (chatPreflightInFlightRef.current || chatStartInFlightRef.current) { + return; + } const currentConversationId = conversationIdRef.current.trim(); const shouldKeepNewConversation = @@ -3932,14 +4193,22 @@ export default function App() { if (typeof document !== "undefined" && document.visibilityState === "hidden") { return; } - recoverVisibleConversationAfterPageRestoreRef.current(api); - if (delayedRestoreTimer !== null) { - window.clearTimeout(delayedRestoreTimer); - } - delayedRestoreTimer = window.setTimeout(() => { - delayedRestoreTimer = null; - recoverVisibleConversationAfterPageRestoreRef.current(api); - }, PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS + 350); + void prepareChatRuntime( + "foreground", + api, + CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS, + ) + .catch(() => undefined) + .finally(() => { + recoverVisibleConversationAfterPageRestoreRef.current(api); + if (delayedRestoreTimer !== null) { + window.clearTimeout(delayedRestoreTimer); + } + delayedRestoreTimer = window.setTimeout(() => { + delayedRestoreTimer = null; + recoverVisibleConversationAfterPageRestoreRef.current(api); + }, PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS + 350); + }); }; const handleVisibilityChange = () => { @@ -3963,7 +4232,7 @@ export default function App() { document.removeEventListener("visibilitychange", handleVisibilityChange); document.removeEventListener("resume", runRecovery); }; - }, [api, historyShareToken, status?.online]); + }, [api, historyShareToken, prepareChatRuntime, status?.online]); async function sendChat(message: string, options?: SendChatOptions) { if (!api || chatBusyRef.current) { @@ -3979,6 +4248,17 @@ export default function App() { setConversationId(activeConversationId); setSelectedHistoryId(activeConversationId); } + const startedAsDraftConversation = isLocalDraftConversationId(activeConversationId); + const pendingDraftConversationId = + pendingDraftConversationMigrationRef.current?.draftConversationId.trim() ?? ""; + if (pendingDraftConversationId && pendingDraftConversationId !== activeConversationId) { + const message = "上一条新会话仍在创建,请等待它出现在历史记录后再发送新会话。"; + updateConversationRuntimeEntry(activeConversationId, (current) => ({ + ...current, + error: message, + })); + return; + } if ( chatStartLocksRef.current.has(activeConversationId) || getConversationAbortController(activeConversationId) !== null || @@ -3996,7 +4276,6 @@ export default function App() { getConversationLiveStreamStore(activeConversationId); const controller = new AbortController(); setConversationAbortController(activeConversationId, controller); - const startedAsDraftConversation = isLocalDraftConversationId(activeConversationId); const clientRequestId = options?.clientRequestId?.trim() || `webui-chat-${activeConversationId}-${crypto.randomUUID()}`; @@ -4022,7 +4301,6 @@ export default function App() { : null; protectedConversationRef.current = activeConversationId; blockedHistoryHydrationConversationIdsRef.current.add(activeConversationId); - setConversationRunningState(activeConversationId, true); if ( resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === activeConversationId @@ -4032,7 +4310,9 @@ export default function App() { updateConversationRuntimeEntry(activeConversationId, (current) => ({ ...current, error: null, - toolStatus: null, + toolStatus: "Starting desktop runtime...", + toolStatusIsCompaction: false, + isSending: true, workdir: effectiveWorkdir || undefined, messages: [ ...current.messages, @@ -4065,6 +4345,14 @@ export default function App() { } let terminalEventSeen = false; + let runStarted = false; + const markRunStarted = () => { + if (runStarted) { + return; + } + runStarted = true; + setConversationRunningState(activeConversationId, true); + }; const runtimeControls = normalizeChatRuntimeControlsForProvider( options?.runtimeControls ?? settings.chatRuntimeControls, { @@ -4073,6 +4361,11 @@ export default function App() { }, ); try { + chatStartInFlightRef.current = true; + const preparedStatus = await prepareChatRuntime("send", api, CHAT_RUNTIME_PREPARE_TIMEOUT_MS); + if (preparedStatus.chat_runtime_ready !== true) { + throw new Error("Desktop chat runtime is not ready. Please retry."); + } for await (const event of api.chat( message, isLocalDraftConversationId(activeConversationId) ? undefined : activeConversationId, @@ -4097,6 +4390,10 @@ export default function App() { migrateConversationSummary(previousConversationId, nextConversationId); activeConversationId = nextConversationId; lockedConversationIds.add(activeConversationId); + if (runStarted) { + setConversationRunningState(previousConversationId, false); + setConversationRunningState(activeConversationId, true); + } } const summary = pickConversationSummary(historyItemsRef.current, activeConversationId); if (!summary && startedAsDraftConversation) { @@ -4121,6 +4418,27 @@ export default function App() { }); } } + if (isChatControlEvent(event)) { + if (isRunningChatControlEvent(event)) { + markRunStarted(); + updateConversationRuntimeEntry(activeConversationId, (current) => ({ + ...current, + toolStatus: VIBING_STATUS, + toolStatusIsCompaction: false, + })); + } else if (isTerminalChatControlEvent(event)) { + terminalEventSeen = true; + clearConversationStreamingState(activeConversationId); + if (event.type === "failed" || event.state === "failed") { + updateConversationRuntimeEntry(activeConversationId, (current) => ({ + ...current, + error: event.message?.trim() || "Desktop runtime did not start the request.", + })); + } + } + continue; + } + markRunStarted(); if (isChatStreamNotAvailableEvent(event)) { terminalEventSeen = true; recoverUnavailableActiveConversationStream(activeConversationId, api); @@ -4143,6 +4461,7 @@ export default function App() { getConversationLiveStreamStore(activeConversationId)?.appendEvent(event, { flush: event.type === "done" || event.type === "error", }); + handleTunnelManagerChatEvent(event); if (event.type === "done" || event.type === "error") { terminalEventSeen = true; markCompletedLiveStream(activeConversationId); @@ -4175,6 +4494,7 @@ export default function App() { } } } finally { + chatStartInFlightRef.current = false; clearConversationStreamingState(activeConversationId); if (status?.online && !terminalEventSeen) { await reloadHistory(api, { @@ -4267,7 +4587,16 @@ export default function App() { protectedConversationRef.current = PROTECTED_DRAFT_CONVERSATION; chatStartLocksRef.current.clear(); submitInFlightRef.current = false; - pendingDraftConversationMigrationRef.current = null; + const pendingDraftConversationId = + pendingDraftConversationMigrationRef.current?.draftConversationId.trim() ?? ""; + const pendingDraftStillActive = + pendingDraftConversationId !== "" && + (localRunningConversationIdsRef.current.has(pendingDraftConversationId) || + getConversationAbortController(pendingDraftConversationId) !== null || + blockedHistoryHydrationConversationIdsRef.current.has(pendingDraftConversationId)); + if (!pendingDraftStillActive) { + pendingDraftConversationMigrationRef.current = null; + } composerRef.current?.clear(); const nextRuntime = createConversationRuntimeEntry({ workdir: options?.workdir?.trim() || undefined, @@ -4276,7 +4605,7 @@ export default function App() { syncVisibleConversationRuntime(nextConversationId, nextRuntime); setSelectedHistory(null); setSelectedHistoryEntries([]); - setPendingUploadedFiles([]); + setPendingUploadsForConversation(nextConversationId, []); } const removeWorkspaceProjectFromSettings = useCallback( @@ -4498,6 +4827,7 @@ export default function App() { blockedHistoryHydrationConversationIdsRef.current.delete(conversationId); clearConversationLiveStream(conversationId); clearCachedComposerDraft(conversationId); + pendingUploadsByConversationRef.current.delete(conversationId); } } if (terminalSessionsToClose.length > 0 && terminalClient) { @@ -4974,7 +5304,7 @@ export default function App() { setHistoryError(null); setChatError(null); composerRef.current?.clear(); - setPendingUploadedFiles([]); + setPendingUploadsForConversation(activeConversationId, []); blockedHistoryHydrationConversationIdsRef.current.add(activeConversationId); invalidateHistoryLoad(); markVisibleConversationRevision(); @@ -5065,10 +5395,17 @@ export default function App() { setChatError(translate("chat.upload.requireWorkdir", settings.locale)); return; } + const targetConversationId = getDisplayedConversationId(); + if (!targetConversationId) { + setChatError("请先选择或创建会话后再上传文件。"); + return; + } + const currentUploads = getPendingUploadsForConversation(targetConversationId); + setPendingUploadsForConversation(targetConversationId, currentUploads); const remainingFileSlots = Math.max( 0, - MAX_UPLOAD_FILES - pendingUploadedFilesRef.current.length, + MAX_UPLOAD_FILES - currentUploads.length, ); if (remainingFileSlots === 0) { setChatError( @@ -5094,15 +5431,16 @@ export default function App() { }); if (result.files.length > 0) { - setPendingUploadedFiles((current) => { + updatePendingUploadsForConversation(targetConversationId, (current) => { const next = mergePendingUploadedFiles(current, result.files).slice( 0, MAX_UPLOAD_FILES, ); - pendingUploadedFilesRef.current = next; return next; }); - composerRef.current?.focus(); + if (isDisplayedConversation(targetConversationId)) { + composerRef.current?.focus(); + } } const warnings: string[] = []; @@ -5119,11 +5457,13 @@ export default function App() { }), ); } - if (warnings.length > 0) { + if (warnings.length > 0 && isDisplayedConversation(targetConversationId)) { setChatError(warnings.join("\n")); } } catch (error) { - setChatError(asErrorMessage(error, "导入文件失败")); + if (isDisplayedConversation(targetConversationId)) { + setChatError(asErrorMessage(error, "导入文件失败")); + } } finally { isUploadingFilesRef.current = false; setIsUploadingFiles(false); @@ -5281,6 +5621,7 @@ export default function App() { sharedHistoryItemsRef.current = []; sharedHistoryListRequestRef.current = null; pendingUploadedFilesRef.current = []; + pendingUploadsByConversationRef.current.clear(); draftConversationPinnedRef.current = false; protectedConversationRef.current = ""; chatStartLocksRef.current.clear(); @@ -5530,6 +5871,10 @@ export default function App() { settings.customSettings, terminalProjectPathKey, ); + const projectToolsTunnelOpen = isProjectToolsTunnelOpen( + settings.customSettings, + terminalProjectPathKey, + ); const projectToolsDisabledMessage = !settingsSyncReady ? "Syncing desktop settings..." : !isAgentMode @@ -5545,6 +5890,15 @@ export default function App() { const gitDisabledMessage = !settings.remote.enableWebGit ? "WebUI Git is disabled in desktop Remote settings." : undefined; + const tunnelEnabled = + settingsSyncReady && settings.remote.enableWebTunnels === true && status?.online === true; + const tunnelDisabledMessage = !settingsSyncReady + ? translate("chat.runtime.tunnelSettingsSyncing", settings.locale) + : !settings.remote.enableWebTunnels + ? translate("projectTools.tunnelWebDisabled", settings.locale) + : status?.online !== true + ? translate("projectTools.tunnelRemoteOffline", settings.locale) + : undefined; const handleOpenWorkspaceFile = useCallback( (path: string) => { if (!terminalProjectPath || !terminalProjectPathKey) return; @@ -5574,6 +5928,7 @@ export default function App() { }, [terminalProjectPath, terminalProjectPathKey], ); + const requestWorkspaceEditorClose = useCallback(() => { setWorkspaceEditorCloseRequestId((current) => current + 1); }, []); @@ -6490,13 +6845,33 @@ export default function App() { if (!text && files.length === 0) { return; } + const uploadConversationId = getDisplayedConversationId(); + const pendingDraftConversationId = + pendingDraftConversationMigrationRef.current?.draftConversationId.trim() ?? + ""; + if ( + pendingDraftConversationId && + pendingDraftConversationId !== uploadConversationId + ) { + const message = + "上一条新会话仍在创建,请等待它出现在历史记录后再发送新会话。"; + if (uploadConversationId) { + updateConversationRuntimeEntry(uploadConversationId, (current) => ({ + ...current, + error: message, + })); + } else { + setChatError(message); + } + return; + } composerRef.current?.clear(); - setPendingUploadedFiles([]); + setPendingUploadsForConversation(uploadConversationId, []); void sendChat(text, { uploadedFiles: files, runtimeControls: chatRuntimeControlsForCurrentProvider, }).catch(() => { - setPendingUploadedFiles((current) => + updatePendingUploadsForConversation(uploadConversationId, (current) => mergePendingUploadedFiles(current, files), ); }); @@ -6510,13 +6885,23 @@ export default function App() { isObservingRemoteLiveConversation ? displayedConversationId : undefined, ); }} + onPrepareChatRuntime={() => { + if (!api || historyShareToken) { + return; + } + void prepareChatRuntime( + "composer-focus", + api, + CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS, + ).catch(() => undefined); + }} onComposerBusyChange={handleComposerBusyChange} onChatRuntimeControlsChange={handleChatRuntimeControlsChange} onPickReadableFiles={() => fileInputRef.current?.click()} onPasteFiles={handleImportReadableFiles} pendingUploadedFiles={pendingUploadedFiles} onRemovePendingUpload={(relativePath) => { - setPendingUploadedFiles((current) => + updatePendingUploadsForConversation(getDisplayedConversationId(), (current) => current.filter((file) => file.relativePath !== relativePath), ); }} @@ -6646,7 +7031,10 @@ export default function App() { theme={settings.theme} disabledMessage={projectToolsDisabledMessage} terminalDisabledMessage={terminalDisabledMessage} - activeTab={settings.customSettings.projectToolsPanel.activeTab} + activeTab={getProjectToolsPanelActiveTab( + settings.customSettings, + terminalProjectPathKey, + )} tabOrder={getProjectToolsPanelTabOrder(settings.customSettings, terminalProjectPathKey)} fileTreeOpen={projectToolsFileTreeOpen} fileTreeState={getProjectToolsFileTreeProjectState( @@ -6657,10 +7045,15 @@ export default function App() { settings.customSettings, terminalProjectPathKey, )} + tunnelOpen={projectToolsTunnelOpen} client={terminalClient} gitClient={gitClient} gitWriteEnabled={settings.remote.enableWebGit} gitDisabledMessage={gitDisabledMessage} + tunnelClient={isAgentMode ? api : null} + tunnelEnabled={tunnelEnabled} + tunnelDisabledMessage={tunnelDisabledMessage} + tunnelRefreshToken={tunnelRefreshToken} onWidthChange={(nextWidth) => setSettings((prev) => updateCustomSettings(prev, { @@ -6673,12 +7066,7 @@ export default function App() { } onActiveTabChange={(activeTab) => setSettings((prev) => - updateCustomSettings(prev, { - projectToolsPanel: { - ...prev.customSettings.projectToolsPanel, - activeTab, - }, - }), + updateProjectToolsPanelActiveTab(prev, terminalProjectPathKey, activeTab), ) } onTabOrderChange={(tabOrder) => @@ -6701,6 +7089,9 @@ export default function App() { updateProjectToolsGitReviewOpen(prev, terminalProjectPathKey, open), ) } + onTunnelOpenChange={(open) => + setSettings((prev) => updateProjectToolsTunnelOpen(prev, terminalProjectPathKey, open)) + } onSessionsChange={handleProjectTerminalSessionsChange} onInsertFileMention={(path, kind) => { composerRef.current?.insertFileMention(path, kind); diff --git a/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx new file mode 100644 index 000000000..0738ddcc5 --- /dev/null +++ b/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx @@ -0,0 +1,943 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useLocale } from "@/i18n"; +import { cn } from "@/lib/shared/utils"; +import type { + TunnelCreateInput, + TunnelSummary as GatewayTunnelSummary, + TunnelUpdateInput, +} from "@/lib/gatewaySocket"; +import { + AlertTriangle, + Check, + ChevronDown, + Clock3, + Copy, + Edit3, + ExternalLink, + Folder, + Globe, + Link2, + Loader2, + Plus, + Trash2, + X, +} from "../icons"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; + +export type { TunnelCreateInput }; +export type TunnelSummary = Omit; + +export type TunnelTtlSeconds = 0 | 900 | 3600 | 14400; + +export type LocalTunnelClient = { + listTunnels(): Promise; + createTunnel(input: TunnelCreateInput): Promise; + updateTunnel(input: TunnelUpdateInput): Promise; + closeTunnel(id: string): Promise; +}; + +type LocalTunnelPanelProps = { + client: LocalTunnelClient; + enabled?: boolean; + disabledMessage?: string; + projectPathKey?: string; + refreshToken?: number; +}; + +type TunnelScope = "project" | "global"; + +const TUNNEL_MANAGER_CHANGED_EVENT = "liveagent:tunnel-manager-changed"; + +const TUNNEL_SCOPE_OPTIONS: Array<{ + scope: TunnelScope; + labelKey: string; + titleKey: string; +}> = [ + { + scope: "project", + labelKey: "projectTools.tunnelScopeProject", + titleKey: "projectTools.tunnelScopeProjectTitle", + }, + { + scope: "global", + labelKey: "projectTools.tunnelScopeGlobal", + titleKey: "projectTools.tunnelScopeGlobalTitle", + }, +]; + +const TTL_OPTIONS: Array<{ value: TunnelTtlSeconds; labelKey: string }> = [ + { value: 900, labelKey: "projectTools.tunnelTtl15m" }, + { value: 3600, labelKey: "projectTools.tunnelTtl1h" }, + { value: 14400, labelKey: "projectTools.tunnelTtl4h" }, + { value: 0, labelKey: "projectTools.tunnelTtlInfinite" }, +]; + +const TUNNEL_INPUT_CLASS = + "h-8 min-w-0 rounded-lg border-border/60 bg-background/80 text-xs transition-[border-color,box-shadow,background-color] focus-visible:border-muted-foreground/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-muted-foreground/15 focus-visible:ring-offset-0"; + +function TtlSegmented({ + value, + onChange, + disabled, +}: { + value: TunnelTtlSeconds; + onChange: (value: TunnelTtlSeconds) => void; + disabled?: boolean; +}) { + const { t } = useLocale(); + return ( +
+ {TTL_OPTIONS.map((option) => { + const active = value === option.value; + return ( + + ); + })} +
+ ); +} + +function validateLocalHttpTarget(input: string) { + const value = input.trim(); + if (!value) return "projectTools.tunnelTargetRequired"; + try { + const url = new URL(value); + if (url.protocol !== "http:") { + return "projectTools.tunnelInvalidUrl"; + } + const hostname = url.hostname.toLowerCase(); + if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(hostname)) { + return "projectTools.tunnelLocalhostOnly"; + } + if (url.username || url.password || url.hash) { + return "projectTools.tunnelInvalidUrl"; + } + } catch { + return "projectTools.tunnelInvalidUrl"; + } + return null; +} + +function asErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function formatRemaining(seconds: number) { + if (seconds <= 0) return "0m"; + const hours = Math.floor(seconds / 3600); + const minutes = Math.ceil((seconds % 3600) / 60); + if (hours <= 0) return `${minutes}m`; + if (minutes >= 60) return `${hours + 1}h`; + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +} + +function formatDateTime(seconds: number) { + if (!seconds) return ""; + return new Date(seconds * 1000).toLocaleString(); +} + +function writeTextToClipboard(text: string) { + if (navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(text).then( + () => true, + () => fallbackWriteTextToClipboard(text), + ); + } + return Promise.resolve(fallbackWriteTextToClipboard(text)); +} + +function fallbackWriteTextToClipboard(text: string) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-9999px"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + try { + return document.execCommand("copy"); + } finally { + document.body.removeChild(textarea); + } +} + +function displayTunnelName(tunnel: TunnelSummary) { + return tunnel.name.trim() || tunnel.targetUrl; +} + +function tunnelStatusKey(status: TunnelSummary["status"]) { + if (status === "expired") return "projectTools.tunnelStatusExpired"; + if (status === "offline") return "projectTools.tunnelStatusOffline"; + return "projectTools.tunnelStatusActive"; +} + +function normalizeProjectPathKey(value: string | undefined) { + return value?.trim() ?? ""; +} + +function ttlFromTunnel(tunnel: TunnelSummary, nowSeconds: number): TunnelTtlSeconds { + if (!tunnel.expiresAt) return 0; + const remaining = Math.max(0, tunnel.expiresAt - nowSeconds); + if (remaining <= 900) return 900; + if (remaining <= 3600) return 3600; + return 14400; +} + +export function LocalTunnelPanel({ + client, + enabled = true, + disabledMessage, + projectPathKey, + refreshToken, +}: LocalTunnelPanelProps) { + const { t } = useLocale(); + const normalizedProjectPathKey = useMemo( + () => normalizeProjectPathKey(projectPathKey), + [projectPathKey], + ); + const [scope, setScope] = useState(() => + normalizeProjectPathKey(projectPathKey) ? "project" : "global", + ); + const [targetUrl, setTargetUrl] = useState("http://localhost:3000"); + const [name, setName] = useState(""); + const [ttlSeconds, setTtlSeconds] = useState(3600); + const [createOpen, setCreateOpen] = useState(true); + const [editingId, setEditingId] = useState(""); + const [editTargetUrl, setEditTargetUrl] = useState(""); + const [editName, setEditName] = useState(""); + const [editTtlSeconds, setEditTtlSeconds] = useState(3600); + const [tunnels, setTunnels] = useState([]); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [savingId, setSavingId] = useState(""); + const [closingId, setClosingId] = useState(""); + const [copiedId, setCopiedId] = useState(""); + const [error, setError] = useState(null); + const [nowSeconds, setNowSeconds] = useState(() => Math.floor(Date.now() / 1000)); + const refreshTokenRef = useRef(refreshToken); + const targetValidationKey = useMemo(() => validateLocalHttpTarget(targetUrl), [targetUrl]); + const editTargetValidationKey = useMemo( + () => (editingId ? validateLocalHttpTarget(editTargetUrl) : null), + [editTargetUrl, editingId], + ); + + const refresh = useCallback( + (options?: { showLoading?: boolean }) => { + const showLoading = options?.showLoading ?? true; + if (showLoading) { + setLoading(true); + } + setError(null); + return client + .listTunnels() + .then((items) => setTunnels(items)) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => { + if (showLoading) { + setLoading(false); + } + }); + }, + [client], + ); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (refreshTokenRef.current === refreshToken) return; + refreshTokenRef.current = refreshToken; + void refresh({ showLoading: false }); + }, [refresh, refreshToken]); + + useEffect(() => { + const handleTunnelManagerChanged = () => { + void refresh({ showLoading: false }); + }; + window.addEventListener(TUNNEL_MANAGER_CHANGED_EVENT, handleTunnelManagerChanged); + return () => + window.removeEventListener(TUNNEL_MANAGER_CHANGED_EVENT, handleTunnelManagerChanged); + }, [refresh]); + + useEffect(() => { + if (!normalizedProjectPathKey && scope === "project") { + setScope("global"); + setError(null); + } + }, [normalizedProjectPathKey, scope]); + + useEffect(() => { + const timer = window.setInterval(() => { + setNowSeconds(Math.floor(Date.now() / 1000)); + }, 1000); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + if (!copiedId) return; + const timer = window.setTimeout(() => setCopiedId(""), 1600); + return () => window.clearTimeout(timer); + }, [copiedId]); + + const createTunnel = useCallback(() => { + const validationKey = validateLocalHttpTarget(targetUrl); + if (validationKey) { + setError(t(validationKey)); + return; + } + if (!enabled || creating) return; + const input: TunnelCreateInput = { + targetUrl: targetUrl.trim(), + name: name.trim() || undefined, + ttlSeconds, + }; + if (scope === "project" && normalizedProjectPathKey) { + input.projectPathKey = normalizedProjectPathKey; + } + setCreating(true); + setError(null); + void client + .createTunnel(input) + .then((created) => { + setTunnels((current) => [ + created, + ...current.filter((item) => item.id !== created.id && item.slug !== created.slug), + ]); + setName(""); + void refresh({ showLoading: false }); + }) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => setCreating(false)); + }, [ + client, + creating, + enabled, + name, + normalizedProjectPathKey, + refresh, + scope, + t, + targetUrl, + ttlSeconds, + ]); + + const beginEdit = useCallback( + (tunnel: TunnelSummary) => { + setEditingId(tunnel.id); + setEditTargetUrl(tunnel.targetUrl); + setEditName(tunnel.name); + setEditTtlSeconds(ttlFromTunnel(tunnel, nowSeconds)); + setError(null); + }, + [nowSeconds], + ); + + const cancelEdit = useCallback(() => { + setEditingId(""); + setEditTargetUrl(""); + setEditName(""); + setEditTtlSeconds(3600); + setError(null); + }, []); + + const updateTunnel = useCallback( + (tunnel: TunnelSummary) => { + const validationKey = validateLocalHttpTarget(editTargetUrl); + if (validationKey) { + setError(t(validationKey)); + return; + } + if (!enabled || savingId) return; + const input: TunnelUpdateInput = { + id: tunnel.id, + targetUrl: editTargetUrl.trim(), + name: editName.trim() || undefined, + ttlSeconds: editTtlSeconds, + }; + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + if (tunnelProjectPathKey) { + input.projectPathKey = tunnelProjectPathKey; + } + setSavingId(tunnel.id); + setError(null); + void client + .updateTunnel(input) + .then((updated) => { + setTunnels((current) => current.map((item) => (item.id === updated.id ? updated : item))); + cancelEdit(); + }) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => setSavingId((current) => (current === tunnel.id ? "" : current))); + }, + [cancelEdit, client, editName, editTargetUrl, editTtlSeconds, enabled, savingId, t], + ); + + const closeTunnel = useCallback( + (id: string) => { + if (!enabled || closingId) return; + setClosingId(id); + setError(null); + void client + .closeTunnel(id) + .then((closed) => { + setTunnels((current) => + current + .filter((item) => item.id !== id) + .concat(closed.status === "active" ? [closed] : []), + ); + }) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => setClosingId((current) => (current === id ? "" : current))); + }, + [client, closingId, enabled], + ); + + const copyLink = useCallback((tunnel: TunnelSummary) => { + if (!tunnel.publicUrl) return; + void writeTextToClipboard(tunnel.publicUrl) + .then((copied) => { + if (copied) { + setCopiedId(tunnel.id); + } + }) + .catch(() => {}); + }, []); + + const openLink = useCallback((tunnel: TunnelSummary) => { + if (!tunnel.publicUrl) return; + window.open(tunnel.publicUrl, "_blank", "noopener,noreferrer"); + }, []); + + const scopedTunnels = useMemo( + () => + tunnels.filter((tunnel) => { + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + if (scope === "project") { + return ( + Boolean(normalizedProjectPathKey) && tunnelProjectPathKey === normalizedProjectPathKey + ); + } + return true; + }), + [normalizedProjectPathKey, scope, tunnels], + ); + const sortedTunnels = useMemo( + () => [...scopedTunnels].sort((a, b) => b.createdAt - a.createdAt), + [scopedTunnels], + ); + const canCreate = + enabled && + !creating && + !targetValidationKey && + (scope !== "project" || Boolean(normalizedProjectPathKey)); + const showCreateForm = scope === "project" && Boolean(normalizedProjectPathKey); + const createFieldsDisabled = !showCreateForm || !createOpen || !enabled || creating; + + return ( +
+
+
+
+ +
+
+
+ {t("projectTools.tunnelTitle")} +
+
+ {t("projectTools.tunnelDescription")} +
+
+
+
+
+ {TUNNEL_SCOPE_OPTIONS.map((option) => { + const active = scope === option.scope; + const disabled = option.scope === "project" && !normalizedProjectPathKey; + const Icon = option.scope === "project" ? Folder : Globe; + return ( + + ); + })} +
+
+ +
+ {disabledMessage ? ( +
+ + {disabledMessage} +
+ ) : null} + + {normalizedProjectPathKey ? ( +
+
+
+ +
+
+
{ + event.preventDefault(); + if (!showCreateForm || !createOpen) return; + createTunnel(); + }} + > +
+ + setTargetUrl(event.target.value)} + placeholder={t("projectTools.tunnelTargetPlaceholder")} + disabled={createFieldsDisabled} + inputMode="url" + autoComplete="off" + spellCheck={false} + className={cn(TUNNEL_INPUT_CLASS, "font-mono")} + /> + {targetValidationKey ? ( +
+ + {t(targetValidationKey)} +
+ ) : null} +
+
+ + setName(event.target.value)} + placeholder={t("projectTools.tunnelNamePlaceholder")} + disabled={createFieldsDisabled} + autoComplete="off" + className={TUNNEL_INPUT_CLASS} + /> +
+
+ + +
+ +
+
+
+
+
+
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
+
+ + {t("projectTools.tunnelListSection")} + + {sortedTunnels.length > 0 ? ( + + {sortedTunnels.length} + + ) : null} +
+ {loading && sortedTunnels.length === 0 ? ( +
+ {t("projectTools.tunnelLoading")} +
+
+
+ ) : sortedTunnels.length === 0 ? ( +
+
+ +
+
+ {t("projectTools.tunnelEmpty")} +
+ {showCreateForm ? ( +
+ {t("projectTools.tunnelEmptyHintCreate")} +
+ ) : normalizedProjectPathKey ? ( +
+ {t("projectTools.tunnelEmptyHintProject")} +
+ ) : null} +
+ ) : ( +
+ {sortedTunnels.map((tunnel) => { + const hasExpiry = tunnel.expiresAt > 0; + const remaining = hasExpiry ? tunnel.expiresAt - nowSeconds : 0; + const expired = tunnel.status === "expired" || (hasExpiry && remaining <= 0); + const isEditing = editingId === tunnel.id; + const updating = savingId === tunnel.id; + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + const handleEditKeyDown = (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") { + event.preventDefault(); + updateTunnel(tunnel); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelEdit(); + } + }; + return ( +
+
+
+ {displayTunnelName(tunnel)} +
+ + + {t(tunnelStatusKey(expired ? "expired" : tunnel.status))} + +
+ + {isEditing ? ( + <> +
+
+ + setEditTargetUrl(event.target.value)} + onKeyDown={handleEditKeyDown} + disabled={!enabled || updating} + inputMode="url" + autoComplete="off" + spellCheck={false} + className={cn(TUNNEL_INPUT_CLASS, "font-mono")} + /> + {editTargetValidationKey ? ( +
+ + {t(editTargetValidationKey)} +
+ ) : null} +
+
+ + setEditName(event.target.value)} + onKeyDown={handleEditKeyDown} + placeholder={t("projectTools.tunnelNamePlaceholder")} + disabled={!enabled || updating} + autoComplete="off" + className={TUNNEL_INPUT_CLASS} + /> +
+
+ + +
+
+
+ + +
+ + ) : ( + <> + +
+ + {t("projectTools.tunnelTarget")} + {tunnel.targetUrl} +
+
+
+ + + + {!hasExpiry + ? t("projectTools.tunnelTtlInfinite") + : expired + ? t("projectTools.tunnelExpired") + : t("projectTools.tunnelExpiresIn").replace( + "{time}", + formatRemaining(remaining), + )} + + + {scope === "global" ? ( + + {t( + tunnelProjectPathKey + ? "projectTools.tunnelScopeProjectBadge" + : "projectTools.tunnelScopeGlobalBadge", + )} + + ) : null} +
+
+ + + +
+
+ + )} +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx index c01212a2b..baf973971 100644 --- a/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/ProjectToolsPanel.tsx @@ -34,6 +34,7 @@ import { ChevronRight, FolderTree, GitBranch, + Globe, GripVertical, Plus, Terminal, @@ -54,6 +55,7 @@ import { type GitCommitContextPayload, type GitFileContextPayload, } from "./GitReviewPanel"; +import { LocalTunnelPanel, type LocalTunnelClient } from "./LocalTunnelPanel"; import { ProjectFileTreePanel } from "./ProjectFileTreePanel"; const MIN_PANEL_WIDTH = 320; @@ -64,6 +66,7 @@ const DEFAULT_TERMINAL_COLS = 80; const DEFAULT_TERMINAL_ROWS = 24; const FILE_TREE_TAB_ID = "__file_tree__"; const GIT_REVIEW_TAB_ID = "__git_review__"; +const TUNNEL_TAB_ID = "__tunnel__"; const PROJECT_TOOLS_RESIZE_END_EVENT = "liveagent:project-tools-resize-end"; type ProjectToolsPanelProps = { @@ -81,16 +84,22 @@ type ProjectToolsPanelProps = { fileTreeOpen: boolean; fileTreeState: ProjectToolsFileTreeProjectState; gitReviewOpen: boolean; + tunnelOpen?: boolean; client: TerminalClient; gitClient?: GitClient | null; gitWriteEnabled?: boolean; gitDisabledMessage?: string; + tunnelClient?: LocalTunnelClient | null; + tunnelEnabled?: boolean; + tunnelDisabledMessage?: string; + tunnelRefreshToken?: number; onWidthChange: (width: number) => void; onActiveTabChange: (tab: ProjectToolsPanelTab) => void; onTabOrderChange?: (tabOrder: string[]) => void; onFileTreeOpenChange: (open: boolean) => void; onFileTreeStateChange: (patch: ProjectToolsFileTreeStatePatch) => void; onGitReviewOpenChange: (open: boolean) => void; + onTunnelOpenChange?: (open: boolean) => void; onSessionsChange?: (sessions: TerminalSession[]) => void; onInsertFileMention?: (path: string, kind: "file" | "dir") => void; onOpenFile?: (path: string) => void; @@ -200,6 +209,10 @@ type ProjectToolsTab = | { id: typeof GIT_REVIEW_TAB_ID; kind: "gitReview"; + } + | { + id: typeof TUNNEL_TAB_ID; + kind: "tunnel"; }; type TabDragState = { @@ -727,16 +740,22 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { fileTreeOpen, fileTreeState, gitReviewOpen, + tunnelOpen = false, client, gitClient, gitWriteEnabled = false, gitDisabledMessage, + tunnelClient, + tunnelEnabled = false, + tunnelDisabledMessage, + tunnelRefreshToken, onWidthChange, onActiveTabChange, onTabOrderChange, onFileTreeOpenChange, onFileTreeStateChange, onGitReviewOpenChange, + onTunnelOpenChange, onSessionsChange, onInsertFileMention, onOpenFile, @@ -790,10 +809,15 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { const isControlled = externalSessions !== undefined; const fileTreeInitialized = Boolean(projectPathKey && fileTreeOpen); const gitReviewInitialized = Boolean(projectPathKey && gitReviewOpen); + const tunnelInitialized = Boolean(tunnelOpen && tunnelClient); + const tunnelAvailable = Boolean(tunnelClient); const previousFileTreeInitializedRef = useRef(fileTreeInitialized); const previousGitReviewInitializedRef = useRef(gitReviewInitialized); + const previousTunnelInitializedRef = useRef(tunnelInitialized); const currentActiveTab: ProjectToolsPanelTab = - activeTab === "gitReview" && gitReviewInitialized + activeTab === "tunnel" && tunnelInitialized + ? "tunnel" + : activeTab === "gitReview" && gitReviewInitialized ? "gitReview" : activeTab === "fileTree" && fileTreeInitialized ? "fileTree" @@ -822,8 +846,11 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { if (gitReviewInitialized) { nextTabs.push({ id: GIT_REVIEW_TAB_ID, kind: "gitReview" }); } + if (tunnelInitialized) { + nextTabs.push({ id: TUNNEL_TAB_ID, kind: "tunnel" }); + } return nextTabs; - }, [fileTreeInitialized, gitReviewInitialized, sessions]); + }, [fileTreeInitialized, gitReviewInitialized, sessions, tunnelInitialized]); const effectiveTabOrder = draftTabOrder ?? tabOrder; const orderedProjectTabs = useMemo( () => orderProjectToolsTabs(visibleTabs, effectiveTabOrder), @@ -914,6 +941,18 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } }, [activeTab, gitReviewInitialized, onActiveTabChange]); + useEffect(() => { + const previousTunnelInitialized = previousTunnelInitializedRef.current; + previousTunnelInitializedRef.current = tunnelInitialized; + if (tunnelInitialized && !previousTunnelInitialized) { + onActiveTabChange("tunnel"); + return; + } + if (!tunnelInitialized && previousTunnelInitialized && activeTab === "tunnel") { + onActiveTabChange("terminal"); + } + }, [activeTab, onActiveTabChange, tunnelInitialized]); + const publishSessions = useCallback( (nextSessions: TerminalSession[], options?: { notifyParent?: boolean }) => { const sorted = sortSessions(nextSessions); @@ -1047,7 +1086,14 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { if (!isOpen) return; const element = tabsScrollRef.current; if (!element) return; - const targetTabId = currentActiveTab === "fileTree" ? FILE_TREE_TAB_ID : activeSession?.id; + const targetTabId = + currentActiveTab === "fileTree" + ? FILE_TREE_TAB_ID + : currentActiveTab === "gitReview" + ? GIT_REVIEW_TAB_ID + : currentActiveTab === "tunnel" + ? TUNNEL_TAB_ID + : activeSession?.id; if (!targetTabId) return; const target = Array.from( element.querySelectorAll("[data-project-tools-tab-id]"), @@ -1540,8 +1586,12 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } }, []); + const showDisabledMessage = Boolean(disabledMessage && !tunnelAvailable && !tunnelInitialized); const showProjectToolsChooser = - projectReady && currentActiveTab === "terminal" && !activeSession; + !showDisabledMessage && + (projectReady || tunnelAvailable) && + currentActiveTab === "terminal" && + !activeSession; const startFileTree = useCallback(() => { setFileTreeInitialized(true); @@ -1598,6 +1648,19 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } }, [activeTab, onActiveTabChange, onGitReviewOpenChange]); + const startTunnel = useCallback(() => { + if (!tunnelClient) return; + onTunnelOpenChange?.(true); + onActiveTabChange("tunnel"); + }, [onActiveTabChange, onTunnelOpenChange, tunnelClient]); + + const closeTunnelTab = useCallback(() => { + onTunnelOpenChange?.(false); + if (activeTab === "tunnel") { + onActiveTabChange("terminal"); + } + }, [activeTab, onActiveTabChange, onTunnelOpenChange]); + const renderCreateTerminalMenuItem = () => { if (shellOptions.length > 1) { return ( @@ -1807,6 +1870,63 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { ); } + if (tab.kind === "tunnel") { + return ( +
+ +
+ ); + } + const session = tab.session; const isPendingClose = pendingCloseSessionId === session.id; const isClosing = closingSessionId === session.id; @@ -1914,7 +2034,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) {
) : null} - {disabledMessage ? ( + {showDisabledMessage ? (
{disabledMessage}
@@ -2024,7 +2152,9 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) {
+
{loading ? (
@@ -2104,6 +2254,22 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { />
) : null} + {tunnelInitialized && tunnelClient ? ( +
+ +
+ ) : null} {sessions.length > 0 ? (
> = { "chat.runtime.webSearchOn": "联网搜索已开启", "chat.runtime.webSearchOff": "联网搜索已关闭", "chat.runtime.webSearchTooltip": "联网搜索", + "chat.runtime.tunnelToolAvailable": "内网穿透工具已可用", + "chat.runtime.tunnelToolUnavailable": "内网穿透工具不可用", + "chat.runtime.tunnelAgentModeRequired": "Agent 模式下可使用内网穿透工具", + "chat.runtime.tunnelWebDisabled": "Remote 设置未允许 WebUI 内网穿透", + "chat.runtime.tunnelRemoteOffline": "Remote Gateway 未连接", + "chat.runtime.tunnelSettingsSyncing": "正在同步桌面端设置", "chat.runtime.reasoning": "思考程度", "chat.emptyRound": "(无回复)", "chat.inputHint": "输入消息,@ 引用文件,Enter 发送,Shift+Enter 换行", @@ -220,6 +226,7 @@ export const translations: Record> = { "projectTools.terminalTitle": "终端", "projectTools.fileTreeTitle": "文件树", "projectTools.gitReviewTitle": "Git 审查", + "projectTools.tunnelTitle": "内网穿透", "projectTools.resizePanel": "调整项目工具栏宽度", "projectTools.getStarted": "开始使用", "projectTools.getStartedHint": "选择一个工具开始", @@ -229,6 +236,13 @@ export const translations: Record> = { "projectTools.fileTreeDescription": "浏览和管理项目文件", "projectTools.newGitReview": "新建审查", "projectTools.gitReviewDescription": "查看代码变更和提交历史", + "projectTools.newTunnel": "新建内网穿透", + "projectTools.tunnelDescription": "通过 Gateway 暴露本机 HTTP 服务", + "projectTools.tunnelScopeGroup": "切换内网穿透视角", + "projectTools.tunnelScopeProject": "当前项目", + "projectTools.tunnelScopeGlobal": "全局", + "projectTools.tunnelScopeProjectTitle": "管理当前项目内网穿透", + "projectTools.tunnelScopeGlobalTitle": "管理全局内网穿透", "projectTools.newProjectTool": "新建项目工具", "projectTools.closePanel": "关闭项目工具栏", "projectTools.close": "关闭", @@ -238,6 +252,45 @@ export const translations: Record> = { "projectTools.closeRunningTerminal": "关闭正在运行的终端「{title}」?", "projectTools.closeFileTree": "关闭文件树", "projectTools.closeGitReview": "关闭 Git 审查", + "projectTools.closeTunnelTab": "关闭内网穿透", + "projectTools.tunnelTargetUrl": "本地服务地址", + "projectTools.tunnelTargetPlaceholder": "http://localhost:3000", + "projectTools.tunnelName": "名称", + "projectTools.tunnelNamePlaceholder": "可选", + "projectTools.tunnelTtl": "有效期", + "projectTools.tunnelTtl15m": "15m", + "projectTools.tunnelTtl1h": "1h", + "projectTools.tunnelTtl4h": "4h", + "projectTools.tunnelTtlInfinite": "无限", + "projectTools.tunnelCreate": "创建临时链接", + "projectTools.tunnelCreating": "创建中...", + "projectTools.tunnelEdit": "编辑链接", + "projectTools.tunnelSave": "保存修改", + "projectTools.tunnelUpdating": "保存中...", + "projectTools.tunnelCancelEdit": "取消编辑", + "projectTools.tunnelLoading": "正在加载内网穿透...", + "projectTools.tunnelEmpty": "还没有内网穿透链接", + "projectTools.tunnelCreateSection": "新建链接", + "projectTools.tunnelListSection": "链接列表", + "projectTools.tunnelEmptyHintCreate": "在上方新建第一个临时链接", + "projectTools.tunnelEmptyHintProject": "切换到「当前项目」即可创建链接", + "projectTools.tunnelTargetRequired": "请输入本地 HTTP 服务地址。", + "projectTools.tunnelInvalidUrl": "请输入有效的 http://localhost 地址,不能包含账号、密码或片段。", + "projectTools.tunnelLocalhostOnly": "仅支持 localhost、127.0.0.1 或 [::1]。", + "projectTools.tunnelRemoteOffline": "Remote Gateway 未连接,连接后才能创建或关闭 tunnel。", + "projectTools.tunnelWebDisabled": "桌面端 Remote 设置未允许 WebUI 创建或关闭内网穿透。", + "projectTools.tunnelStatusActive": "运行中", + "projectTools.tunnelStatusExpired": "已过期", + "projectTools.tunnelStatusOffline": "离线", + "projectTools.tunnelTarget": "目标", + "projectTools.tunnelExpiresIn": "剩余 {time}", + "projectTools.tunnelExpired": "已过期", + "projectTools.tunnelScopeProjectBadge": "项目", + "projectTools.tunnelScopeGlobalBadge": "全局", + "projectTools.tunnelCopyLink": "复制链接", + "projectTools.tunnelCopied": "已复制", + "projectTools.tunnelOpenLink": "打开链接", + "projectTools.tunnelClose": "删除链接", "projectTools.gitReview.viewChanges": "查看更改", "projectTools.gitReview.discardChanges": "放弃更改", "projectTools.gitReview.stageChanges": "暂存更改", @@ -909,6 +962,9 @@ export const translations: Record> = { "settings.remoteWebGit": "允许 WebUI Git", "settings.remoteWebGitHint": "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可对本机项目执行分支、暂存、提交和同步操作。", + "settings.remoteWebTunnels": "允许 WebUI 内网穿透", + "settings.remoteWebTunnelsHint": + "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可为本机 localhost HTTP 服务创建和关闭临时访问链接。", "settings.remoteHeartbeat": "心跳间隔", "settings.remoteHeartbeatUnit": "秒", "settings.remoteHeartbeatHint": "与 Gateway 之间的心跳检测间隔,用于维持连接和检测在线状态", @@ -1243,6 +1299,12 @@ export const translations: Record> = { "chat.runtime.webSearchOn": "Web search enabled", "chat.runtime.webSearchOff": "Web search disabled", "chat.runtime.webSearchTooltip": "Toggle web search", + "chat.runtime.tunnelToolAvailable": "Tunnel tool available", + "chat.runtime.tunnelToolUnavailable": "Tunnel tool unavailable", + "chat.runtime.tunnelAgentModeRequired": "Tunnel tool requires Agent mode", + "chat.runtime.tunnelWebDisabled": "Remote WebUI tunnels are not allowed", + "chat.runtime.tunnelRemoteOffline": "Remote Gateway is offline", + "chat.runtime.tunnelSettingsSyncing": "Syncing desktop settings", "chat.runtime.reasoning": "Thinking effort", "chat.emptyRound": "(No reply)", "chat.inputHint": "Type a message, @ to mention files, Enter to send, Shift+Enter for newline", @@ -1389,6 +1451,7 @@ export const translations: Record> = { "projectTools.terminalTitle": "Terminal", "projectTools.fileTreeTitle": "File Tree", "projectTools.gitReviewTitle": "Git Review", + "projectTools.tunnelTitle": "Tunnel", "projectTools.resizePanel": "Resize project tools panel", "projectTools.getStarted": "Get Started", "projectTools.getStartedHint": "Choose a tool to begin", @@ -1398,6 +1461,13 @@ export const translations: Record> = { "projectTools.fileTreeDescription": "Browse and manage project files", "projectTools.newGitReview": "New Review", "projectTools.gitReviewDescription": "Review code changes and commit history", + "projectTools.newTunnel": "New Tunnel", + "projectTools.tunnelDescription": "Expose local HTTP services through Gateway", + "projectTools.tunnelScopeGroup": "Switch tunnel scope", + "projectTools.tunnelScopeProject": "Current Project", + "projectTools.tunnelScopeGlobal": "Global", + "projectTools.tunnelScopeProjectTitle": "Manage current project tunnels", + "projectTools.tunnelScopeGlobalTitle": "Manage global tunnels", "projectTools.newProjectTool": "New project tool", "projectTools.closePanel": "Close project tools panel", "projectTools.close": "Close", @@ -1407,6 +1477,48 @@ export const translations: Record> = { "projectTools.closeRunningTerminal": 'Close running terminal "{title}"?', "projectTools.closeFileTree": "Close File Tree", "projectTools.closeGitReview": "Close Git Review", + "projectTools.closeTunnelTab": "Close Tunnel", + "projectTools.tunnelTargetUrl": "Local service URL", + "projectTools.tunnelTargetPlaceholder": "http://localhost:3000", + "projectTools.tunnelName": "Name", + "projectTools.tunnelNamePlaceholder": "Optional", + "projectTools.tunnelTtl": "TTL", + "projectTools.tunnelTtl15m": "15m", + "projectTools.tunnelTtl1h": "1h", + "projectTools.tunnelTtl4h": "4h", + "projectTools.tunnelTtlInfinite": "Unlimited", + "projectTools.tunnelCreate": "Create temporary link", + "projectTools.tunnelCreating": "Creating...", + "projectTools.tunnelEdit": "Edit link", + "projectTools.tunnelSave": "Save changes", + "projectTools.tunnelUpdating": "Saving...", + "projectTools.tunnelCancelEdit": "Cancel edit", + "projectTools.tunnelLoading": "Loading tunnels...", + "projectTools.tunnelEmpty": "No tunnel links yet", + "projectTools.tunnelCreateSection": "New link", + "projectTools.tunnelListSection": "Links", + "projectTools.tunnelEmptyHintCreate": "Create your first temporary link above", + "projectTools.tunnelEmptyHintProject": "Switch to Current Project to create links", + "projectTools.tunnelTargetRequired": "Enter a local HTTP service URL.", + "projectTools.tunnelInvalidUrl": + "Enter a valid http://localhost URL without credentials or fragments.", + "projectTools.tunnelLocalhostOnly": "Only localhost, 127.0.0.1, or [::1] are supported.", + "projectTools.tunnelRemoteOffline": + "Remote Gateway is not connected. Connect it before creating or closing tunnels.", + "projectTools.tunnelWebDisabled": + "Desktop Remote settings do not allow WebUI tunnel create or close.", + "projectTools.tunnelStatusActive": "Active", + "projectTools.tunnelStatusExpired": "Expired", + "projectTools.tunnelStatusOffline": "Offline", + "projectTools.tunnelTarget": "Target", + "projectTools.tunnelExpiresIn": "{time} left", + "projectTools.tunnelExpired": "Expired", + "projectTools.tunnelScopeProjectBadge": "Project", + "projectTools.tunnelScopeGlobalBadge": "Global", + "projectTools.tunnelCopyLink": "Copy link", + "projectTools.tunnelCopied": "Copied", + "projectTools.tunnelOpenLink": "Open link", + "projectTools.tunnelClose": "Delete link", "projectTools.gitReview.viewChanges": "View Changes", "projectTools.gitReview.discardChanges": "Discard Changes", "projectTools.gitReview.stageChanges": "Stage Changes", @@ -2107,6 +2219,9 @@ export const translations: Record> = { "settings.remoteWebGit": "Allow WebUI Git", "settings.remoteWebGitHint": "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can run branch, stage, commit, and sync operations on local projects.", + "settings.remoteWebTunnels": "Allow WebUI Tunnels", + "settings.remoteWebTunnelsHint": + "Controlled by the desktop Remote settings. When enabled, authenticated WebUI clients can create and close temporary links for local localhost HTTP services.", "settings.remoteHeartbeat": "Heartbeat Interval", "settings.remoteHeartbeatUnit": "seconds", "settings.remoteHeartbeatHint": diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index d81da0402..e09449d6f 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -1,6 +1,7 @@ import type { GatewaySettingsSyncPayload } from "@/lib/settings/sync"; import type { HistoryMessageRef } from "@/lib/chat/conversationState"; import type { PendingUploadedFile } from "@/lib/chat/uploadedFiles"; + import type { TerminalEvent, TerminalSession, @@ -10,6 +11,7 @@ import type { import type { AgentStatus, + ChatControlEvent, ChatEvent, ConversationSummary, GatewayHistoryEvent, @@ -53,6 +55,7 @@ type ChatStreamState = { lastSeq: number; resuming: boolean; attachedSocket: WebSocket | null; + startWatchdogId?: number; abortHandler?: () => void; }; @@ -154,6 +157,58 @@ export type UploadedImagePreviewResponse = { data: string; }; +export type TunnelCreateInput = { + targetUrl: string; + name?: string; + ttlSeconds: 0 | 900 | 3600 | 14400; + projectPathKey?: string; +}; + +export type TunnelUpdateInput = { + id: string; + targetUrl: string; + name?: string; + ttlSeconds: 0 | 900 | 3600 | 14400; + projectPathKey?: string; +}; + +export type TunnelSummary = { + id: string; + slug: string; + name: string; + targetUrl: string; + publicUrl: string; + createdAt: number; + expiresAt: number; + activeConnections: number; + status: "active" | "expired" | "offline"; + projectPathKey: string; +}; + +type RawTunnelSummary = { + id?: string; + slug?: string; + name?: string; + targetUrl?: string; + target_url?: string; + publicUrl?: string; + public_url?: string; + createdAt?: number; + created_at?: number; + expiresAt?: number; + expires_at?: number; + activeConnections?: number; + active_connections?: number; + status?: string; + projectPathKey?: string; + project_path_key?: string; +}; + +type RawTunnelResponse = { + tunnel?: RawTunnelSummary; + tunnels?: RawTunnelSummary[]; +}; + type HistoryGetOptions = { maxMessages?: number; }; @@ -323,6 +378,9 @@ const RECONNECT_INITIAL_DELAY_MS = 500; const RECONNECT_MAX_DELAY_MS = 15_000; const RECONNECT_NOTICE_DELAY_MS = 10_000; const SOCKET_INBOUND_STALL_MS = 25_000; +const FOREGROUND_SOCKET_RECYCLE_IDLE_MS = 10_000; +const FOREGROUND_WAKEUP_RECENCY_MS = 15_000; +const CHAT_STREAM_TRANSPORT_STALL_MS = 8_000; type RuntimeHost = { location?: { @@ -359,11 +417,55 @@ function getRuntimeOrigin() { return ""; } +function isForegroundWakeupEvent(event?: Event) { + const type = event?.type ?? ""; + if (type === "pagehide" || type === "freeze") { + return false; + } + if (typeof document !== "undefined" && type === "visibilitychange") { + return document.visibilityState === "visible"; + } + if ( + typeof document !== "undefined" && + document.visibilityState === "hidden" && + type !== "online" + ) { + return false; + } + return true; +} + function readChatEventSeq(event: ChatEvent) { const seq = event.seq; return typeof seq === "number" && Number.isFinite(seq) && seq > 0 ? Math.floor(seq) : 0; } +function isChatControlEvent( + event: ChatEvent | null | undefined, +): event is ChatControlEvent { + switch (event?.type) { + case "accepted": + case "delivered": + case "claimed": + case "starting": + case "started": + case "progress": + case "completed": + case "failed": + case "cancelled": + return true; + default: + return false; + } +} + +function isTerminalChatControlEvent(event: ChatEvent | null | undefined) { + if (!isChatControlEvent(event)) { + return false; + } + return event?.state === "completed" || event?.state === "failed" || event?.state === "cancelled"; +} + function normalizeAfterSeq(value: unknown) { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; } @@ -443,6 +545,42 @@ function normalizeTerminalEvent(input: RawTerminalEvent): TerminalEvent | null { }; } +function normalizeTunnelStatus(input: unknown): TunnelSummary["status"] { + return input === "expired" || input === "offline" ? input : "active"; +} + +function fallbackTunnelPublicUrl(slug: string) { + const origin = getRuntimeOrigin().replace(/\/$/, ""); + return origin && slug ? `${origin}/t/${slug}/` : ""; +} + +function normalizeTunnelSummary(input: RawTunnelSummary): TunnelSummary { + const slug = input.slug?.trim() ?? ""; + return { + id: input.id?.trim() ?? "", + slug, + name: input.name?.trim() ?? "", + targetUrl: input.targetUrl ?? input.target_url ?? "", + publicUrl: input.publicUrl ?? input.public_url ?? fallbackTunnelPublicUrl(slug), + createdAt: Number(input.createdAt ?? input.created_at ?? 0), + expiresAt: Number(input.expiresAt ?? input.expires_at ?? 0), + activeConnections: Number(input.activeConnections ?? input.active_connections ?? 0), + status: normalizeTunnelStatus(input.status), + projectPathKey: (input.projectPathKey ?? input.project_path_key ?? "").trim(), + }; +} + +function normalizeTunnelListResponse(input: RawTunnelResponse): TunnelSummary[] { + return (input.tunnels ?? []).map(normalizeTunnelSummary); +} + +function normalizeTunnelResponse(input: RawTunnelResponse): TunnelSummary { + if (!input.tunnel) { + throw new Error("Tunnel response did not include a tunnel"); + } + return normalizeTunnelSummary(input.tunnel); +} + function normalizeOptionalOffset(value: unknown) { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) @@ -512,14 +650,33 @@ export class GatewayWebSocketClient { private reconnectNoticeTimer: number | null = null; private reconnectAttempt = 0; private reconnecting = false; - private readonly reconnectWakeup = () => { - this.scheduleReconnect(0); + private lastForegroundWakeupAt = 0; + private prepareRuntimePromise: Promise | null = null; + private readonly reconnectWakeup = (event?: Event) => { + this.noteForegroundWakeup(event); }; constructor(private readonly token: string) { this.installReconnectWakeups(); } + noteForegroundWakeup(event?: Event) { + if (this.disposed || !isForegroundWakeupEvent(event)) { + return; + } + const now = Date.now(); + this.lastForegroundWakeupAt = now; + if ( + this.socket?.readyState === WebSocket.OPEN && + this.authenticated && + this.shouldRecycleAuthenticatedSocket(now) + ) { + this.handleDisconnect(this.buildTransportStallError("after page restore")); + return; + } + this.scheduleReconnect(0); + } + async getStatus(): Promise { const status = await this.requestWithRecovery("status.get", {}); this.lastStatus = status; @@ -527,6 +684,22 @@ export class GatewayWebSocketClient { return status; } + async prepareChatRuntime(_reason?: string): Promise { + if (this.prepareRuntimePromise) { + return this.prepareRuntimePromise; + } + this.noteForegroundWakeup(); + this.prepareRuntimePromise = (async () => { + await this.ensureConnected({ resumeStreams: true }); + const status = await this.getStatus(); + this.emitStatus(status, null); + return status; + })().finally(() => { + this.prepareRuntimePromise = null; + }); + return this.prepareRuntimePromise; + } + subscribeStatus(listener: StatusListener): () => void { this.statusListeners.add(listener); if (this.lastStatus || this.lastStatusError) { @@ -607,6 +780,7 @@ export class GatewayWebSocketClient { if (active?.conversationId) { void this.cancelChat(active.conversationId).catch(() => undefined); } + this.clearChatStreamStartWatchdog(streamState); this.chatStreams.delete(requestId); queue.close(); }; @@ -619,6 +793,7 @@ export class GatewayWebSocketClient { } try { + const requestStartedAt = Date.now(); this.sendEnvelope({ id: requestId, type: "chat.start", @@ -655,6 +830,12 @@ export class GatewayWebSocketClient { }, }); streamState.attachedSocket = this.socket; + this.armChatStreamStartWatchdog( + requestId, + streamState, + requestStartedAt, + "chat.start", + ); for await (const event of queue) { yield event; @@ -662,6 +843,9 @@ export class GatewayWebSocketClient { } finally { const active = this.chatStreams.get(requestId); active?.abortHandler?.(); + if (active) { + this.clearChatStreamStartWatchdog(active); + } this.chatStreams.delete(requestId); } } @@ -698,6 +882,7 @@ export class GatewayWebSocketClient { if (signal) { const handleAbort = () => { + this.clearChatStreamStartWatchdog(streamState); void this.detachChatStream(requestId).catch(() => undefined); this.chatStreams.delete(requestId); queue.close(); @@ -711,6 +896,7 @@ export class GatewayWebSocketClient { } try { + const requestStartedAt = Date.now(); this.sendEnvelope({ id: requestId, type: "chat.attach", @@ -720,6 +906,12 @@ export class GatewayWebSocketClient { }, }); streamState.attachedSocket = this.socket; + this.armChatStreamStartWatchdog( + requestId, + streamState, + requestStartedAt, + "chat.attach", + ); for await (const event of queue) { yield event; @@ -727,6 +919,9 @@ export class GatewayWebSocketClient { } finally { const active = this.chatStreams.get(requestId); active?.abortHandler?.(); + if (active) { + this.clearChatStreamStartWatchdog(active); + } if (active) { await this.detachChatStream(requestId).catch(() => undefined); this.chatStreams.delete(requestId); @@ -883,6 +1078,49 @@ export class GatewayWebSocketClient { }); } + async listTunnels(): Promise { + return normalizeTunnelListResponse( + await this.requestWithRecovery("tunnel.list", {}), + ); + } + + async createTunnel(input: TunnelCreateInput): Promise { + const payload: Record = { + targetUrl: input.targetUrl, + ttlSeconds: input.ttlSeconds, + name: input.name, + }; + if (input.projectPathKey?.trim()) { + payload.projectPathKey = input.projectPathKey.trim(); + } + return normalizeTunnelResponse( + await this.request("tunnel.create", payload), + ); + } + + async updateTunnel(input: TunnelUpdateInput): Promise { + const payload: Record = { + id: input.id, + targetUrl: input.targetUrl, + ttlSeconds: input.ttlSeconds, + name: input.name, + }; + if (input.projectPathKey?.trim()) { + payload.projectPathKey = input.projectPathKey.trim(); + } + return normalizeTunnelResponse( + await this.request("tunnel.update", payload), + ); + } + + async closeTunnel(id: string): Promise { + return normalizeTunnelResponse( + await this.request("tunnel.close", { + id, + }), + ); + } + async listHistory( page: number, pageSize: number, @@ -1544,6 +1782,7 @@ export class GatewayWebSocketClient { if (!this.socket || !this.authenticated || this.socket.readyState !== WebSocket.OPEN) { return; } + const requestStartedAt = Date.now(); if (stream.kind === "attach") { this.sendEnvelope({ id: requestId, @@ -1565,6 +1804,12 @@ export class GatewayWebSocketClient { }); } stream.attachedSocket = this.socket; + this.armChatStreamStartWatchdog( + requestId, + stream, + requestStartedAt, + stream.kind === "attach" ? "chat.attach" : "chat.resume", + ); } catch { this.scheduleReconnect(); } finally { @@ -1572,6 +1817,45 @@ export class GatewayWebSocketClient { } } + private armChatStreamStartWatchdog( + requestId: string, + stream: ChatStreamState, + requestStartedAt: number, + action: string, + ) { + this.clearChatStreamStartWatchdog(stream); + const socket = this.socket; + if (!socket) { + return; + } + const host = getRuntimeHost(); + stream.startWatchdogId = host.setTimeout(() => { + const active = this.chatStreams.get(requestId); + if (!active || active !== stream || active.lastSeq > 0) { + return; + } + active.startWatchdogId = undefined; + if ( + this.socket === socket && + active.attachedSocket === socket && + this.authenticated && + socket.readyState === WebSocket.OPEN && + this.lastInboundAt <= requestStartedAt + ) { + this.handleDisconnect(this.buildTransportStallError(`while waiting for ${action}`)); + } + }, CHAT_STREAM_TRANSPORT_STALL_MS); + } + + private clearChatStreamStartWatchdog(stream: ChatStreamState) { + if (stream.startWatchdogId === undefined) { + return; + } + const host = getRuntimeHost(); + host.clearTimeout(stream.startWatchdogId); + stream.startWatchdogId = undefined; + } + private async detachChatStream(requestId: string) { if (this.disposed) { return; @@ -1651,7 +1935,7 @@ export class GatewayWebSocketClient { return; } - if (envelope.type === "chat.event" && requestId) { + if ((envelope.type === "chat.event" || envelope.type === "chat.control") && requestId) { const stream = this.chatStreams.get(requestId); if (!stream) { return; @@ -1667,8 +1951,13 @@ export class GatewayWebSocketClient { if (event?.conversation_id) { stream.conversationId = event.conversation_id; } + this.clearChatStreamStartWatchdog(stream); stream.queue.push(event); - if (event?.type === "done" || event?.type === "error") { + if ( + event?.type === "done" || + event?.type === "error" || + isTerminalChatControlEvent(event) + ) { stream.abortHandler?.(); stream.queue.close(); this.chatStreams.delete(requestId); @@ -1676,7 +1965,7 @@ export class GatewayWebSocketClient { return; } - if (envelope.type === "conversation.event") { + if (envelope.type === "conversation.event" || envelope.type === "conversation.control") { const event = envelope.payload as ChatEvent; if (event?.conversation_id) { this.emitConversation(event); @@ -1688,6 +1977,7 @@ export class GatewayWebSocketClient { const stream = this.chatStreams.get(requestId); if (stream) { const message = typeof envelope.error === "string" ? envelope.error : "Request failed"; + this.clearChatStreamStartWatchdog(stream); stream.queue.push({ type: "error", message, @@ -1746,11 +2036,13 @@ export class GatewayWebSocketClient { const streams = [...this.chatStreams.values()]; this.chatStreams.clear(); for (const stream of streams) { + this.clearChatStreamStartWatchdog(stream); stream.abortHandler?.(); stream.queue.fail(error); } } else { for (const stream of this.chatStreams.values()) { + this.clearChatStreamStartWatchdog(stream); stream.attachedSocket = null; } } @@ -1781,12 +2073,22 @@ export class GatewayWebSocketClient { } private shouldRecycleAuthenticatedSocket(now = Date.now()) { + if ( + this.socket === null || + !this.authenticated || + this.socket.readyState !== WebSocket.OPEN || + this.lastInboundAt <= 0 + ) { + return false; + } + if (now - this.lastInboundAt >= SOCKET_INBOUND_STALL_MS) { + return true; + } return ( - this.socket !== null && - this.authenticated && - this.socket.readyState === WebSocket.OPEN && - this.lastInboundAt > 0 && - now - this.lastInboundAt >= SOCKET_INBOUND_STALL_MS + this.lastForegroundWakeupAt > 0 && + now - this.lastForegroundWakeupAt <= FOREGROUND_WAKEUP_RECENCY_MS && + this.lastInboundAt < this.lastForegroundWakeupAt && + now - this.lastInboundAt >= FOREGROUND_SOCKET_RECYCLE_IDLE_MS ); } @@ -1815,6 +2117,7 @@ export class GatewayWebSocketClient { export type GatewayWebSocketClientLike = { getStatus(): Promise; + prepareChatRuntime(reason?: string): Promise; subscribeStatus(listener: StatusListener): () => void; subscribeHistory(listener: HistoryListener): () => void; subscribeConversation(listener: ConversationListener): () => void; @@ -1869,6 +2172,10 @@ export type GatewayWebSocketClientLike = { closeTerminal(sessionId: string, projectPathKey?: string): Promise; closeProjectTerminals(projectPathKey: string): Promise; detachTerminal(sessionId: string, projectPathKey?: string): Promise; + listTunnels(): Promise; + createTunnel(input: TunnelCreateInput): Promise; + updateTunnel(input: TunnelUpdateInput): Promise; + closeTunnel(id: string): Promise; listHistory(page: number, pageSize: number, filter?: HistoryListFilter): Promise; listHistoryWorkdirs(): Promise; listSharedHistory(page: number, pageSize: number): Promise; @@ -2029,6 +2336,10 @@ type SharedWorkerClientRequestMessage = connection_id: string; stream_id: string; } + | { + type: "wakeup"; + connection_id: string; + } | { type: "dispose"; connection_id: string; @@ -2067,6 +2378,9 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { private terminalListeners = new Set(); private lastStatus: AgentStatus | null = null; private lastStatusError: string | null = null; + private readonly workerWakeup = (event?: Event) => { + this.postWorkerWakeup(event); + }; constructor(private readonly token: string) { this.connectionID = this.nextRequestId("connection"); @@ -2087,6 +2401,7 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { this.handleDisconnect(new Error("Gateway SharedWorker message failed")); }; this.port.start(); + this.installWorkerWakeups(); const host = getRuntimeHost(); this.readyTimeoutId = host.setTimeout(() => { this.handleDisconnect(new Error("Gateway SharedWorker connection timed out")); @@ -2110,6 +2425,13 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { return status; } + async prepareChatRuntime(reason?: string): Promise { + const status = await this.request("chat.prepare", { reason: reason ?? "" }); + this.statusRefreshRequested = false; + this.emitStatus(status, null); + return status; + } + subscribeStatus(listener: StatusListener): () => void { this.statusListeners.add(listener); if (this.lastStatus || this.lastStatusError) { @@ -2429,6 +2751,47 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { }); } + async listTunnels(): Promise { + return normalizeTunnelListResponse(await this.request("tunnel.list", {})); + } + + async createTunnel(input: TunnelCreateInput): Promise { + const payload: Record = { + targetUrl: input.targetUrl, + ttlSeconds: input.ttlSeconds, + name: input.name, + }; + if (input.projectPathKey?.trim()) { + payload.projectPathKey = input.projectPathKey.trim(); + } + return normalizeTunnelResponse( + await this.request("tunnel.create", payload), + ); + } + + async updateTunnel(input: TunnelUpdateInput): Promise { + const payload: Record = { + id: input.id, + targetUrl: input.targetUrl, + ttlSeconds: input.ttlSeconds, + name: input.name, + }; + if (input.projectPathKey?.trim()) { + payload.projectPathKey = input.projectPathKey.trim(); + } + return normalizeTunnelResponse( + await this.request("tunnel.update", payload), + ); + } + + async closeTunnel(id: string): Promise { + return normalizeTunnelResponse( + await this.request("tunnel.close", { + id, + }), + ); + } + async listHistory( page: number, pageSize: number, @@ -2681,6 +3044,7 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { return; } this.disposed = true; + this.uninstallWorkerWakeups(); try { this.postMessage({ type: "dispose", connection_id: this.connectionID }); } catch { @@ -2808,6 +3172,48 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { await this.connectPromise; } + private installWorkerWakeups() { + if (typeof window !== "undefined" && typeof window.addEventListener === "function") { + window.addEventListener("online", this.workerWakeup); + window.addEventListener("focus", this.workerWakeup); + window.addEventListener("pageshow", this.workerWakeup); + window.addEventListener("pagehide", this.workerWakeup); + } + if (typeof document !== "undefined" && typeof document.addEventListener === "function") { + document.addEventListener("visibilitychange", this.workerWakeup); + document.addEventListener("freeze", this.workerWakeup as EventListener); + document.addEventListener("resume", this.workerWakeup as EventListener); + } + } + + private uninstallWorkerWakeups() { + if (typeof window !== "undefined" && typeof window.removeEventListener === "function") { + window.removeEventListener("online", this.workerWakeup); + window.removeEventListener("focus", this.workerWakeup); + window.removeEventListener("pageshow", this.workerWakeup); + window.removeEventListener("pagehide", this.workerWakeup); + } + if (typeof document !== "undefined" && typeof document.removeEventListener === "function") { + document.removeEventListener("visibilitychange", this.workerWakeup); + document.removeEventListener("freeze", this.workerWakeup as EventListener); + document.removeEventListener("resume", this.workerWakeup as EventListener); + } + } + + private postWorkerWakeup(event?: Event) { + if (this.disposed || !isForegroundWakeupEvent(event)) { + return; + } + try { + this.postMessage({ + type: "wakeup", + connection_id: this.connectionID, + }); + } catch { + this.handleDisconnect(new Error("Gateway SharedWorker wakeup failed")); + } + } + private postMessage(message: SharedWorkerClientRequestMessage) { this.port.postMessage(message); } @@ -2900,7 +3306,7 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { stream.conversationId = event.conversation_id; } stream.queue.push(event); - if (event?.type === "done" || event?.type === "error") { + if (event?.type === "done" || event?.type === "error" || isTerminalChatControlEvent(event)) { stream.abortHandler?.(); stream.queue.close(); this.chatStreams.delete(streamId); diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts index ad95bd461..d219eed1d 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts @@ -63,6 +63,10 @@ type WorkerClientRequest = connection_id: string; stream_id: string; } + | { + type: "wakeup"; + connection_id: string; + } | { type: "dispose"; connection_id: string; @@ -94,6 +98,7 @@ type SharedWorkerScope = { const clients = new Map(); const portStates = new Map(); const TERMINAL_DETACH_GRACE_MS = 250; +const MANAGED_CLIENT_WARM_WINDOW_MS = 10 * 60_000; function asErrorMessage(error: unknown, fallback: string) { if (error instanceof Error && error.message.trim()) { @@ -244,7 +249,7 @@ function scheduleManagedClientCleanup(client: ManagedClient) { client.terminalDetachTimers.clear(); client.client.dispose(); clients.delete(client.token); - }, 60_000); + }, MANAGED_CLIENT_WARM_WINDOW_MS); } function clearManagedClientCleanup(client: ManagedClient) { @@ -364,6 +369,9 @@ async function resolveRequest(client: GatewayWebSocketClient, method: string, pa switch (method) { case "status.get": return client.getStatus(); + case "chat.prepare": + client.noteForegroundWakeup(); + return client.prepareChatRuntime("shared-worker"); case "fs.roots": return client.listFsRoots(); case "fs.list_dirs": @@ -574,6 +582,71 @@ async function resolveRequest(client: GatewayWebSocketClient, method: string, pa String(body.project_path_key ?? ""), ); return undefined; + case "tunnel.list": + return { + tunnels: await client.listTunnels(), + }; + case "tunnel.create": { + const projectPathKey = + typeof body.projectPathKey === "string" + ? body.projectPathKey.trim() + : typeof body.project_path_key === "string" + ? body.project_path_key.trim() + : ""; + return { + tunnel: await client.createTunnel({ + targetUrl: String(body.targetUrl ?? body.target_url ?? ""), + ttlSeconds: + body.ttlSeconds === 0 || + body.ttlSeconds === 900 || + body.ttlSeconds === 3600 || + body.ttlSeconds === 14400 + ? body.ttlSeconds + : body.ttl_seconds === 0 || + body.ttl_seconds === 900 || + body.ttl_seconds === 3600 || + body.ttl_seconds === 14400 + ? body.ttl_seconds + : 3600, + name: typeof body.name === "string" ? body.name : undefined, + ...(projectPathKey ? { projectPathKey } : {}), + }), + }; + } + case "tunnel.update": { + const projectPathKey = + typeof body.projectPathKey === "string" + ? body.projectPathKey.trim() + : typeof body.project_path_key === "string" + ? body.project_path_key.trim() + : ""; + return { + tunnel: await client.updateTunnel({ + id: String(body.id ?? body.tunnelId ?? body.tunnel_id ?? body.slug ?? ""), + targetUrl: String(body.targetUrl ?? body.target_url ?? ""), + ttlSeconds: + body.ttlSeconds === 0 || + body.ttlSeconds === 900 || + body.ttlSeconds === 3600 || + body.ttlSeconds === 14400 + ? body.ttlSeconds + : body.ttl_seconds === 0 || + body.ttl_seconds === 900 || + body.ttl_seconds === 3600 || + body.ttl_seconds === 14400 + ? body.ttl_seconds + : 3600, + name: typeof body.name === "string" ? body.name : undefined, + ...(projectPathKey ? { projectPathKey } : {}), + }), + }; + } + case "tunnel.close": + return { + tunnel: await client.closeTunnel( + String(body.id ?? body.tunnelId ?? body.tunnel_id ?? body.slug ?? ""), + ), + }; case "provider.models": return client.getProviderModels( String(body.type ?? ""), @@ -873,6 +946,9 @@ function handlePortMessage(port: MessagePort, raw: unknown) { case "chat.detach": handleChatDetach(state, message); return; + case "wakeup": + state.client.client.noteForegroundWakeup(); + return; } } diff --git a/crates/agent-gateway/web/src/lib/gatewayTypes.ts b/crates/agent-gateway/web/src/lib/gatewayTypes.ts index c2a446687..3c55a1937 100644 --- a/crates/agent-gateway/web/src/lib/gatewayTypes.ts +++ b/crates/agent-gateway/web/src/lib/gatewayTypes.ts @@ -8,11 +8,18 @@ import type { export type AgentStatus = { online: boolean; + agent_ready?: boolean; + chat_runtime_ready?: boolean; agent_id?: string; agent_version?: string; session_id?: string; connected_since?: number; last_heartbeat?: number; + runtime_state?: "ready" | "draining" | "busy" | "suspended" | string; + runtime_last_heartbeat?: number; + runtime_worker_id?: string; + runtime_visible?: boolean; + runtime_active_run_count?: number; }; export type GatewaySelectedModel = { @@ -51,6 +58,38 @@ export type ChatCheckpointPayload = { }; }; +export type ChatRunControlState = + | "queued" + | "delivered" + | "claimed" + | "starting" + | "running" + | "completed" + | "failed" + | "cancelled"; + +export type ChatControlEvent = { + type: + | "accepted" + | "delivered" + | "claimed" + | "starting" + | "started" + | "progress" + | "completed" + | "failed" + | "cancelled"; + request_id?: string; + client_request_id?: string; + conversation_id?: string; + run_epoch?: number; + state?: ChatRunControlState; + error_code?: string; + message?: string; + seq?: number; + workdir?: string; +}; + export type ChatEvent = ( | { type: "token"; @@ -118,6 +157,7 @@ export type ChatEvent = ( conversation_id?: string; } | { type: "error"; message: string; round?: number; conversation_id?: string } + | ChatControlEvent ) & { seq?: number; workdir?: string }; export type CronManagePayload = { diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 41d85387e..9ec5e88e3 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -142,11 +142,12 @@ export type ChatSidebarSettings = { recentCollapsed: boolean; }; -export type ProjectToolsPanelTab = "terminal" | "fileTree" | "gitReview"; +export type ProjectToolsPanelTab = "terminal" | "fileTree" | "gitReview" | "tunnel"; export type ProjectToolsPanelSettings = { width: number; activeTab: ProjectToolsPanelTab; + activeTabs: Record; tabOrders: Record; }; @@ -169,6 +170,11 @@ export type ProjectToolsGitReviewSettings = { openVersion: number; }; +export type ProjectToolsTunnelSettings = { + openProjectPathKeys: string[]; + openVersion: number; +}; + export type ProjectToolsFileTreeStatePatch = Partial & { bumpRevision?: boolean; bumpStateVersion?: boolean; @@ -180,6 +186,7 @@ export type CustomSettings = { projectToolsPanel: ProjectToolsPanelSettings; projectToolsFileTree: ProjectToolsFileTreeSettings; projectToolsGitReview: ProjectToolsGitReviewSettings; + projectToolsTunnel: ProjectToolsTunnelSettings; }; export type SystemSettings = { @@ -267,6 +274,7 @@ export type RemoteSettings = { heartbeatInterval: number; enableWebTerminal: boolean; enableWebGit: boolean; + enableWebTunnels: boolean; }; export type AppSettings = { @@ -1055,6 +1063,7 @@ export function normalizeRemoteSettings(input: unknown): RemoteSettings { heartbeatInterval: normalizePositiveInteger(obj.heartbeatInterval, 30), enableWebTerminal: obj.enableWebTerminal === true, enableWebGit: obj.enableWebGit === true, + enableWebTunnels: obj.enableWebTunnels === true, }; } @@ -1545,6 +1554,23 @@ export function normalizeProjectToolsGitReviewSettings( }; } +export function normalizeProjectToolsTunnelSettings( + input: unknown, +): ProjectToolsTunnelSettings { + const obj = (input && typeof input === "object" ? input : {}) as Record; + const openProjectPathKeys = Array.from( + new Set( + (Array.isArray(obj.openProjectPathKeys) ? obj.openProjectPathKeys : []) + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean), + ), + ).sort(); + return { + openProjectPathKeys, + openVersion: normalizeIntegerInRange(obj.openVersion, 0, Number.MAX_SAFE_INTEGER, 0), + }; +} + export function normalizeProjectToolsPanelTabOrder(input: unknown): string[] { if (!Array.isArray(input)) return []; const order: string[] = []; @@ -1560,6 +1586,33 @@ export function normalizeProjectToolsPanelTabOrder(input: unknown): string[] { return order; } +function isProjectToolsPanelTab(input: unknown): input is ProjectToolsPanelTab { + return input === "terminal" || + input === "fileTree" || + input === "gitReview" || + input === "tunnel"; +} + +export function normalizeProjectToolsPanelActiveTab(input: unknown): ProjectToolsPanelTab { + return isProjectToolsPanelTab(input) ? input : "fileTree"; +} + +export function normalizeProjectToolsPanelActiveTabs( + input: unknown, +): Record { + const rawTabs = ( + input && typeof input === "object" && !Array.isArray(input) ? input : {} + ) as Record; + const activeTabs: Record = {}; + for (const [pathKey, value] of Object.entries(rawTabs)) { + const normalizedPathKey = workspaceProjectPathKey(pathKey); + if (!normalizedPathKey || !isProjectToolsPanelTab(value)) continue; + activeTabs[normalizedPathKey] = value; + if (Object.keys(activeTabs).length >= 100) break; + } + return activeTabs; +} + export function normalizeProjectToolsPanelTabOrders(input: unknown): Record { const rawOrders = ( input && typeof input === "object" && !Array.isArray(input) ? input : {} @@ -1590,12 +1643,9 @@ export function normalizeCustomSettings( const projectToolsPanel = (obj.projectToolsPanel && typeof obj.projectToolsPanel === "object" ? obj.projectToolsPanel : {}) as Record; - const projectToolsPanelActiveTab = - projectToolsPanel.activeTab === "terminal" || - projectToolsPanel.activeTab === "fileTree" || - projectToolsPanel.activeTab === "gitReview" - ? projectToolsPanel.activeTab - : "fileTree"; + const projectToolsPanelActiveTab = normalizeProjectToolsPanelActiveTab( + projectToolsPanel.activeTab, + ); const projectToolsFileTree = ( obj.projectToolsFileTree && typeof obj.projectToolsFileTree === "object" ? obj.projectToolsFileTree @@ -1606,6 +1656,11 @@ export function normalizeCustomSettings( ? obj.projectToolsGitReview : {} ) as unknown; + const projectToolsTunnel = ( + obj.projectToolsTunnel && typeof obj.projectToolsTunnel === "object" + ? obj.projectToolsTunnel + : {} + ) as unknown; return { conversationTitleModel: normalizeSelectedModelForProviders( normalizeSelectedModel(obj.conversationTitleModel), @@ -1623,10 +1678,12 @@ export function normalizeCustomSettings( 420, ), activeTab: projectToolsPanelActiveTab, + activeTabs: normalizeProjectToolsPanelActiveTabs(projectToolsPanel.activeTabs), tabOrders: normalizeProjectToolsPanelTabOrders(projectToolsPanel.tabOrders), }, projectToolsFileTree: normalizeProjectToolsFileTreeSettings(projectToolsFileTree), projectToolsGitReview: normalizeProjectToolsGitReviewSettings(projectToolsGitReview), + projectToolsTunnel: normalizeProjectToolsTunnelSettings(projectToolsTunnel), }; } @@ -1661,6 +1718,7 @@ export function getDefaultSettings(): AppSettings { heartbeatInterval: 30, enableWebTerminal: false, enableWebGit: false, + enableWebTunnels: false, }, memory: normalizeMemorySettings({}, customProviders), customSettings: normalizeCustomSettings({}, customProviders), @@ -1807,6 +1865,10 @@ function hasProjectToolsGitReviewSessionState(state: ProjectToolsGitReviewSettin return state.openVersion > 0 || state.openProjectPathKeys.length > 0; } +function hasProjectToolsTunnelSessionState(state: ProjectToolsTunnelSettings): boolean { + return state.openVersion > 0 || state.openProjectPathKeys.length > 0; +} + export function preserveProjectToolsSessionState( next: AppSettings, current: AppSettings, @@ -1817,6 +1879,9 @@ export function preserveProjectToolsSessionState( const currentGitReview = normalizeProjectToolsGitReviewSettings( current.customSettings.projectToolsGitReview, ); + const currentTunnel = normalizeProjectToolsTunnelSettings( + current.customSettings.projectToolsTunnel, + ); return normalizeSettings({ ...next, @@ -1828,6 +1893,9 @@ export function preserveProjectToolsSessionState( projectToolsGitReview: hasProjectToolsGitReviewSessionState(currentGitReview) ? currentGitReview : next.customSettings.projectToolsGitReview, + projectToolsTunnel: hasProjectToolsTunnelSessionState(currentTunnel) + ? currentTunnel + : next.customSettings.projectToolsTunnel, }, }); } @@ -1841,6 +1909,56 @@ export function getProjectToolsPanelTabOrder( return customSettings.projectToolsPanel.tabOrders[normalizedPathKey] ?? []; } +export function getProjectToolsPanelActiveTab( + customSettings: CustomSettings, + projectPathKey: string, +): ProjectToolsPanelTab { + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + if (!normalizedPathKey) return customSettings.projectToolsPanel.activeTab; + return ( + customSettings.projectToolsPanel.activeTabs[normalizedPathKey] ?? + customSettings.projectToolsPanel.activeTab + ); +} + +export function updateProjectToolsPanelActiveTab( + prev: AppSettings, + projectPathKey: string, + activeTab: ProjectToolsPanelTab, +): AppSettings { + const nextActiveTab = normalizeProjectToolsPanelActiveTab(activeTab); + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + if (!normalizedPathKey) { + if (prev.customSettings.projectToolsPanel.activeTab === nextActiveTab) return prev; + return updateCustomSettings(prev, { + projectToolsPanel: { + ...prev.customSettings.projectToolsPanel, + activeTab: nextActiveTab, + }, + }); + } + + const currentProjectActiveTab = + prev.customSettings.projectToolsPanel.activeTabs[normalizedPathKey]; + if ( + prev.customSettings.projectToolsPanel.activeTab === nextActiveTab && + currentProjectActiveTab === nextActiveTab + ) { + return prev; + } + + return updateCustomSettings(prev, { + projectToolsPanel: { + ...prev.customSettings.projectToolsPanel, + activeTab: nextActiveTab, + activeTabs: { + ...prev.customSettings.projectToolsPanel.activeTabs, + [normalizedPathKey]: nextActiveTab, + }, + }, + }); +} + function projectToolsPanelTabOrderEqual(left: readonly string[], right: readonly string[]) { return left.length === right.length && left.every((item, index) => item === right[index]); } @@ -1882,6 +2000,10 @@ export function removeProjectToolsProjectState( prev.customSettings.projectToolsPanel.tabOrders, normalizedPathKey, ); + const hasActiveTab = Object.prototype.hasOwnProperty.call( + prev.customSettings.projectToolsPanel.activeTabs, + normalizedPathKey, + ); const openProjectPathKeys = prev.customSettings.projectToolsFileTree.openProjectPathKeys .map((pathKey) => workspaceProjectPathKey(pathKey)) .filter(Boolean); @@ -1898,6 +2020,14 @@ export function removeProjectToolsProjectState( ); const removedGitReviewOpenProjectPathKey = nextGitReviewOpenProjectPathKeys.length !== gitReviewOpenProjectPathKeys.length; + const tunnelOpenProjectPathKeys = prev.customSettings.projectToolsTunnel.openProjectPathKeys + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean); + const nextTunnelOpenProjectPathKeys = tunnelOpenProjectPathKeys.filter( + (pathKey) => pathKey !== normalizedPathKey, + ); + const removedTunnelOpenProjectPathKey = + nextTunnelOpenProjectPathKeys.length !== tunnelOpenProjectPathKeys.length; const hasFileTreeProjectState = Object.prototype.hasOwnProperty.call( prev.customSettings.projectToolsFileTree.projects, normalizedPathKey, @@ -1906,8 +2036,10 @@ export function removeProjectToolsProjectState( if ( !hasTabOrder && + !hasActiveTab && !removedOpenProjectPathKey && !removedGitReviewOpenProjectPathKey && + !removedTunnelOpenProjectPathKey && !hasFileTreeProjectState ) { return prev; @@ -1919,6 +2051,12 @@ export function removeProjectToolsProjectState( if (hasTabOrder) { delete tabOrders[normalizedPathKey]; } + const activeTabs = hasActiveTab + ? { ...prev.customSettings.projectToolsPanel.activeTabs } + : prev.customSettings.projectToolsPanel.activeTabs; + if (hasActiveTab) { + delete activeTabs[normalizedPathKey]; + } const projects = hasFileTreeProjectState ? { ...prev.customSettings.projectToolsFileTree.projects } @@ -1930,6 +2068,7 @@ export function removeProjectToolsProjectState( return updateCustomSettings(prev, { projectToolsPanel: { ...prev.customSettings.projectToolsPanel, + activeTabs, tabOrders, }, projectToolsFileTree: { @@ -1951,6 +2090,15 @@ export function removeProjectToolsProjectState( ? prev.customSettings.projectToolsGitReview.openVersion + 1 : prev.customSettings.projectToolsGitReview.openVersion, }, + projectToolsTunnel: { + ...prev.customSettings.projectToolsTunnel, + openProjectPathKeys: removedTunnelOpenProjectPathKey + ? nextTunnelOpenProjectPathKeys.sort() + : prev.customSettings.projectToolsTunnel.openProjectPathKeys, + openVersion: removedTunnelOpenProjectPathKey + ? prev.customSettings.projectToolsTunnel.openVersion + 1 + : prev.customSettings.projectToolsTunnel.openVersion, + }, }); } @@ -2042,6 +2190,44 @@ export function updateProjectToolsGitReviewOpen( }); } +export function isProjectToolsTunnelOpen( + customSettings: CustomSettings, + projectPathKey: string, +): boolean { + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + return ( + normalizedPathKey !== "" && + customSettings.projectToolsTunnel.openProjectPathKeys.includes(normalizedPathKey) + ); +} + +export function updateProjectToolsTunnelOpen( + prev: AppSettings, + projectPathKey: string, + open: boolean, +): AppSettings { + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + if (!normalizedPathKey) return prev; + const openProjectPathKeys = new Set( + prev.customSettings.projectToolsTunnel.openProjectPathKeys + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean), + ); + if (openProjectPathKeys.has(normalizedPathKey) === open) return prev; + if (open) { + openProjectPathKeys.add(normalizedPathKey); + } else { + openProjectPathKeys.delete(normalizedPathKey); + } + return updateCustomSettings(prev, { + projectToolsTunnel: { + ...prev.customSettings.projectToolsTunnel, + openProjectPathKeys: Array.from(openProjectPathKeys).sort(), + openVersion: prev.customSettings.projectToolsTunnel.openVersion + 1, + }, + }); +} + function projectToolsFileTreeProjectStateEqual( left: ProjectToolsFileTreeProjectState, right: ProjectToolsFileTreeProjectState, diff --git a/crates/agent-gateway/web/src/lib/settings/storage.ts b/crates/agent-gateway/web/src/lib/settings/storage.ts index 118099bbf..b87d00929 100644 --- a/crates/agent-gateway/web/src/lib/settings/storage.ts +++ b/crates/agent-gateway/web/src/lib/settings/storage.ts @@ -8,6 +8,9 @@ import { normalizeChatRuntimeControls, normalizeProjectToolsFileTreeSettings, normalizeProjectToolsGitReviewSettings, + normalizeProjectToolsTunnelSettings, + normalizeProjectToolsPanelActiveTab, + normalizeProjectToolsPanelActiveTabs, normalizeProjectToolsPanelTabOrders, type ChatRuntimeControls, normalizeSkillsSettings, @@ -56,6 +59,7 @@ function toPersistedLocalCustomSettings( ...customSettings, projectToolsFileTree: normalizeProjectToolsFileTreeSettings({}), projectToolsGitReview: normalizeProjectToolsGitReviewSettings({}), + projectToolsTunnel: normalizeProjectToolsTunnelSettings({}), }; } @@ -89,12 +93,9 @@ function readLocalUiSettings(): { typeof legacyTerminalPanel.width === "string" ? Number(legacyTerminalPanel.width) : 420; - const projectToolsPanelActiveTab = - projectToolsPanel.activeTab === "terminal" || - projectToolsPanel.activeTab === "fileTree" || - projectToolsPanel.activeTab === "gitReview" - ? projectToolsPanel.activeTab - : "fileTree"; + const projectToolsPanelActiveTab = normalizeProjectToolsPanelActiveTab( + projectToolsPanel.activeTab, + ); return toPersistedLocalCustomSettings({ conversationTitleModel: normalizeSelectedModel(obj.conversationTitleModel), chatSidebar: { @@ -106,10 +107,12 @@ function readLocalUiSettings(): { ? Math.min(1280, Math.max(320, Math.floor(projectToolsPanelWidth))) : 420, activeTab: projectToolsPanelActiveTab, + activeTabs: normalizeProjectToolsPanelActiveTabs(projectToolsPanel.activeTabs), tabOrders: normalizeProjectToolsPanelTabOrders(projectToolsPanel.tabOrders), }, projectToolsFileTree: normalizeProjectToolsFileTreeSettings({}), projectToolsGitReview: normalizeProjectToolsGitReviewSettings({}), + projectToolsTunnel: normalizeProjectToolsTunnelSettings({}), }); } diff --git a/crates/agent-gateway/web/src/lib/settings/sync.ts b/crates/agent-gateway/web/src/lib/settings/sync.ts index 67d94940a..fda0eeacc 100644 --- a/crates/agent-gateway/web/src/lib/settings/sync.ts +++ b/crates/agent-gateway/web/src/lib/settings/sync.ts @@ -2,6 +2,7 @@ import { normalizeChatRuntimeControls, normalizeProjectToolsFileTreeSettings, normalizeProjectToolsGitReviewSettings, + normalizeProjectToolsTunnelSettings, normalizeSettings, workspaceProjectPathKey, type AppSettings, @@ -14,6 +15,10 @@ export type GatewaySettingsSyncProvider = Omit< > & { apiKeyConfigured?: boolean; }; +export type GatewaySettingsSyncCustomSettings = Omit< + Partial, + "projectToolsPanel" +>; export type GatewaySettingsSyncPayload = { system: AppSettings["system"]; @@ -22,9 +27,12 @@ export type GatewaySettingsSyncPayload = { agents: AppSettings["agents"]; hooks: AppSettings["hooks"]; cron: AppSettings["cron"]; - remote?: Pick; + remote?: Pick< + AppSettings["remote"], + "enableWebTerminal" | "enableWebGit" | "enableWebTunnels" + >; memory: AppSettings["memory"]; - customSettings: Partial; + customSettings: GatewaySettingsSyncCustomSettings; skills: AppSettings["skills"]; chatRuntimeControls: AppSettings["chatRuntimeControls"]; selectedModel: AppSettings["selectedModel"] | null; @@ -85,9 +93,10 @@ function collectProviderApiKeyUpdates( return Object.keys(updates).length > 0 ? updates : undefined; } -function syncableCustomSettings(customSettings: AppSettings["customSettings"]) { - const syncable = { ...customSettings } as Partial; - delete syncable.projectToolsPanel; +function syncableCustomSettings( + customSettings: AppSettings["customSettings"], +): GatewaySettingsSyncCustomSettings { + const { projectToolsPanel: _projectToolsPanel, ...syncable } = customSettings; return { ...syncable, chatSidebar: { @@ -255,7 +264,8 @@ function mergeSyncedRemoteSettings( const source = asObject(incoming); if ( !Object.prototype.hasOwnProperty.call(source, "enableWebTerminal") && - !Object.prototype.hasOwnProperty.call(source, "enableWebGit") + !Object.prototype.hasOwnProperty.call(source, "enableWebGit") && + !Object.prototype.hasOwnProperty.call(source, "enableWebTunnels") ) { return current; } @@ -267,6 +277,9 @@ function mergeSyncedRemoteSettings( enableWebGit: Object.prototype.hasOwnProperty.call(source, "enableWebGit") ? source.enableWebGit === true : current.enableWebGit, + enableWebTunnels: Object.prototype.hasOwnProperty.call(source, "enableWebTunnels") + ? source.enableWebTunnels === true + : current.enableWebTunnels, }; } @@ -327,6 +340,21 @@ function mergeSyncedProjectToolsGitReviewSettings( }; } +function mergeSyncedProjectToolsTunnelSettings( + current: AppSettings["customSettings"]["projectToolsTunnel"], + incoming: unknown, +): AppSettings["customSettings"]["projectToolsTunnel"] { + const currentState = normalizeProjectToolsTunnelSettings(current); + const incomingState = normalizeProjectToolsTunnelSettings(incoming); + const openFromIncoming = incomingState.openVersion >= currentState.openVersion; + return { + openProjectPathKeys: openFromIncoming + ? incomingState.openProjectPathKeys + : currentState.openProjectPathKeys, + openVersion: Math.max(currentState.openVersion, incomingState.openVersion), + }; +} + export function buildGatewaySettingsSyncPayload( settings: AppSettings, options: { includeProviderApiKeyUpdates?: boolean } = {}, @@ -341,6 +369,7 @@ export function buildGatewaySettingsSyncPayload( remote: { enableWebTerminal: settings.remote.enableWebTerminal, enableWebGit: settings.remote.enableWebGit, + enableWebTunnels: settings.remote.enableWebTunnels, }, memory: settings.memory, customSettings: syncableCustomSettings(settings.customSettings), @@ -374,9 +403,9 @@ export function applyGatewaySettingsSyncPayload( ? (source.memory as AppSettings["memory"] | null | undefined) ?? {} : current.memory; const customSettings = Object.prototype.hasOwnProperty.call(source, "customSettings") - ? (source.customSettings as AppSettings["customSettings"] | null | undefined) ?? {} + ? (source.customSettings as GatewaySettingsSyncCustomSettings | null | undefined) ?? {} : current.customSettings; - const incomingCustomSettings = customSettings as Partial; + const incomingCustomSettings = customSettings as GatewaySettingsSyncCustomSettings; return normalizeSettings({ ...current, @@ -413,6 +442,15 @@ export function applyGatewaySettingsSyncPayload( incomingCustomSettings.projectToolsGitReview, ) : current.customSettings.projectToolsGitReview, + projectToolsTunnel: Object.prototype.hasOwnProperty.call( + incomingCustomSettings, + "projectToolsTunnel", + ) + ? mergeSyncedProjectToolsTunnelSettings( + current.customSettings.projectToolsTunnel, + incomingCustomSettings.projectToolsTunnel, + ) + : current.customSettings.projectToolsTunnel, chatSidebar: current.customSettings.chatSidebar, projectToolsPanel: current.customSettings.projectToolsPanel, }, diff --git a/crates/agent-gateway/web/src/lib/webSettings.ts b/crates/agent-gateway/web/src/lib/webSettings.ts index e53b3d040..57b829f17 100644 --- a/crates/agent-gateway/web/src/lib/webSettings.ts +++ b/crates/agent-gateway/web/src/lib/webSettings.ts @@ -2,6 +2,7 @@ import { getDefaultSettings, normalizeProjectToolsFileTreeSettings, normalizeProjectToolsGitReviewSettings, + normalizeProjectToolsTunnelSettings, normalizeSettings, type AppSettings, } from "@/lib/settings"; @@ -40,6 +41,7 @@ function stripSessionOnlyProjectToolsState(settings: AppSettings): AppSettings { ...settings.customSettings, projectToolsFileTree: normalizeProjectToolsFileTreeSettings({}), projectToolsGitReview: normalizeProjectToolsGitReviewSettings({}), + projectToolsTunnel: normalizeProjectToolsTunnelSettings({}), }, }; } diff --git a/crates/agent-gateway/web/src/main.tsx b/crates/agent-gateway/web/src/main.tsx index 4cbece945..ab3c36d5a 100644 --- a/crates/agent-gateway/web/src/main.tsx +++ b/crates/agent-gateway/web/src/main.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import { StatusDashboardPage } from "./pages/StatusDashboardPage"; import "./index.css"; import "react-complex-tree/lib/style-modern.css"; import "streamdown/styles.css"; @@ -9,8 +10,11 @@ import "./styles.css"; document.documentElement.dataset.liveagentWebui = "gateway"; +const dashboardPaths = new Set(["/dashboard", "/status-board", "/observatory"]); +const Root = dashboardPaths.has(window.location.pathname) ? StatusDashboardPage : App; + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + , ); diff --git a/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx b/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx new file mode 100644 index 000000000..6d8ddc9f9 --- /dev/null +++ b/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx @@ -0,0 +1,1362 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +import { + AlertCircle, + Bot, + Brain, + CheckCircle2, + Cloud, + ExternalLink, + Globe2, + HardDrive, + History, + Loader2, + LogOut, + MessageSquareText, + Plug, + Radio, + RefreshCw, + Server, + Shield, + Sparkles, + Terminal, + Timer, + Wifi, + WifiOff, + Wrench, + Zap, + type IconComponent, +} from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { normalizeGatewayAccessToken, verifyGatewayAccessToken } from "@/lib/gatewayAuth"; +import { + getGatewayWebSocketClient, + resetGatewayWebSocketClient, + type GatewayWebSocketClientLike, + type TunnelSummary, +} from "@/lib/gatewaySocket"; +import type { + AgentStatus, + ChatEvent, + ConversationSummary, + GatewayProviderSummary, + HistoryList, + HistoryWorkdirSummary, +} from "@/lib/gatewayTypes"; +import { cn } from "@/lib/shared/utils"; +import type { GatewaySettingsSyncPayload } from "@/lib/settings/sync"; +import { clearToken, loadToken, saveToken } from "@/lib/storage"; +import type { TerminalSession } from "@/lib/terminal/types"; +import { LoginPage } from "./LoginPage"; + +type DashboardTone = "cyan" | "violet" | "rose" | "amber" | "emerald" | "slate"; + +type DashboardEvent = { + id: string; + at: number; + title: string; + detail: string; + tone: DashboardTone; + conversationId?: string; + workdir?: string; +}; + +type LiveCounters = { + events: number; + tokenChunks: number; + tokenChars: number; + thinking: number; + toolCalls: number; + toolResults: number; + searches: number; + completions: number; + errors: number; + startedAt: number; +}; + +type PendingCounters = Omit; + +type SnapshotState = { + loading: boolean; + error: string | null; + lastRefreshAt: number; +}; + +type MetricCard = { + label: string; + value: string; + unit: string; + detail: string; + tone: DashboardTone; + icon: IconComponent; +}; + +type FactItem = { + label: string; + value: string; + unit?: string; + note?: string; + tone?: DashboardTone; +}; + +type LoadSegment = { + label: string; + value: number; + unit: string; + width: number; + tone: DashboardTone; +}; + +const HISTORY_PAGE_SIZE = 80; +const SNAPSHOT_REFRESH_MS = 10_000; +const LIVE_FLUSH_MS = 500; +const TOKEN_EVENT_MIN_INTERVAL_MS = 1_200; +const MAX_RECENT_EVENTS = 12; + +const initialCounters = (): LiveCounters => ({ + events: 0, + tokenChunks: 0, + tokenChars: 0, + thinking: 0, + toolCalls: 0, + toolResults: 0, + searches: 0, + completions: 0, + errors: 0, + startedAt: Date.now(), +}); + +const initialPendingCounters = (): PendingCounters => ({ + events: 0, + tokenChunks: 0, + tokenChars: 0, + thinking: 0, + toolCalls: 0, + toolResults: 0, + searches: 0, + completions: 0, + errors: 0, +}); + +function readDashboardTokenSeed() { + const params = new URLSearchParams(window.location.search); + const queryToken = params.get("token") ?? params.get("access_token") ?? ""; + return normalizeGatewayAccessToken(queryToken || loadToken()); +} + +function stripDashboardTokenFromUrl() { + const url = new URL(window.location.href); + if (!url.searchParams.has("token") && !url.searchParams.has("access_token")) { + return; + } + url.searchParams.delete("token"); + url.searchParams.delete("access_token"); + window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); +} + +function asErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + if (typeof error === "string" && error.trim()) { + return error.trim(); + } + return fallback; +} + +function normalizeEpochMs(value: number | null | undefined) { + if (!value || !Number.isFinite(value)) { + return 0; + } + return value > 10_000_000_000 ? value : value * 1000; +} + +function formatDuration(ms: number) { + if (!Number.isFinite(ms) || ms <= 0) { + return "0 s"; + } + const seconds = Math.floor(ms / 1000); + if (seconds < 60) { + return `${seconds} s`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes} min ${seconds % 60} s`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours} h ${minutes % 60} min`; + } + const days = Math.floor(hours / 24); + return `${days} d ${hours % 24} h`; +} + +function formatClock(ms: number) { + if (!ms) { + return "--:--:--"; + } + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(new Date(ms)); +} + +function formatDateTime(ms: number) { + if (!ms) { + return "未知"; + } + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(new Date(ms)); +} + +function compactNumber(value: number) { + return new Intl.NumberFormat("zh-CN", { notation: "compact", maximumFractionDigits: 1 }).format( + Math.max(0, value), + ); +} + +function percentage(value: number) { + return `${Math.round(Math.max(0, Math.min(100, value)))}%`; +} + +function formatRuntimeState(status: AgentStatus | null) { + const explicit = status?.runtime_state?.trim(); + if (explicit) { + return explicit; + } + if (status?.online) { + return status.chat_runtime_ready ? "ready" : "connected"; + } + return "offline"; +} + +function formatBooleanFlag(enabled: boolean | undefined) { + if (typeof enabled !== "boolean") { + return "--"; + } + return enabled ? "ON" : "OFF"; +} + +function truncateMiddle(value: string, maxLength = 34) { + const text = value.trim(); + if (text.length <= maxLength) { + return text; + } + const head = Math.ceil((maxLength - 1) * 0.56); + const tail = Math.floor((maxLength - 1) * 0.44); + return `${text.slice(0, head)}…${text.slice(-tail)}`; +} + +function basename(path: string) { + const normalized = path.trim().replace(/\\/g, "/").replace(/\/+$/, ""); + if (!normalized) { + return "未命名项目"; + } + return normalized.split("/").filter(Boolean).pop() ?? normalized; +} + +function getConversationTitle(conversation: ConversationSummary | undefined, fallback: string) { + const title = conversation?.title?.trim(); + return title || fallback; +} + +function buildRunningConversations(history: HistoryList | null) { + if (!history) { + return []; + } + const byId = new Map(history.conversations.map((item) => [item.id, item])); + const runningIds = new Set(); + for (const id of history.running_conversation_ids ?? []) { + if (id.trim()) { + runningIds.add(id.trim()); + } + } + for (const item of history.running_conversations ?? []) { + if (item.conversation_id.trim()) { + runningIds.add(item.conversation_id.trim()); + } + } + return Array.from(runningIds).map((id) => { + const conversation = byId.get(id); + const runtime = history.running_conversations?.find((item) => item.conversation_id === id); + return { + id, + title: getConversationTitle(conversation, `会话 ${truncateMiddle(id, 12)}`), + cwd: runtime?.cwd?.trim() || conversation?.cwd?.trim() || "", + updatedAt: normalizeEpochMs(runtime?.updated_at || conversation?.updated_at), + messageCount: conversation?.message_count ?? 0, + provider: conversation?.provider_id?.trim() || "", + model: conversation?.model?.trim() || "", + }; + }); +} + +function updateHistoryListWithEvent(history: HistoryList | null, event: any): HistoryList | null { + if (!history) { + return history; + } + const conversationId = typeof event.conversation_id === "string" ? event.conversation_id.trim() : ""; + if (!conversationId) { + return history; + } + + if (event.kind === "delete") { + return { + ...history, + total_count: Math.max(0, history.total_count - 1), + conversations: history.conversations.filter((item) => item.id !== conversationId), + running_conversation_ids: (history.running_conversation_ids ?? []).filter((id) => id !== conversationId), + running_conversations: (history.running_conversations ?? []).filter( + (item) => item.conversation_id !== conversationId, + ), + }; + } + + if (event.kind === "running" || event.kind === "idle") { + const currentIds = new Set(history.running_conversation_ids ?? []); + if (event.kind === "running") { + currentIds.add(conversationId); + } else { + currentIds.delete(conversationId); + } + return { + ...history, + running_conversation_ids: Array.from(currentIds), + running_conversations: (history.running_conversations ?? []).filter((item) => { + if (item.conversation_id !== conversationId) { + return true; + } + return event.kind === "running"; + }), + }; + } + + const conversation = event.conversation as ConversationSummary | undefined; + if (event.kind !== "upsert" || !conversation?.id) { + return history; + } + + const without = history.conversations.filter((item) => item.id !== conversation.id); + const conversations = [conversation, ...without] + .sort((a, b) => (b.updated_at ?? 0) - (a.updated_at ?? 0)) + .slice(0, HISTORY_PAGE_SIZE); + return { + ...history, + total_count: Math.max(history.total_count, conversations.length), + conversations, + }; +} + +function classifyChatEvent(event: ChatEvent) { + const type = String(event.type); + switch (type) { + case "token": + return { tone: "cyan" as const, title: "Token 流" }; + case "thinking": + return { tone: "violet" as const, title: "推理脉冲" }; + case "tool_call": + return { tone: "amber" as const, title: "工具调用" }; + case "tool_result": { + const isError = "isError" in event && event.isError === true; + return { tone: isError ? ("rose" as const) : ("emerald" as const), title: "工具回传" }; + } + case "hosted_search": + return { tone: "cyan" as const, title: "联网检索" }; + case "tool_status": + return { tone: "slate" as const, title: "工具状态" }; + case "done": + case "completed": + return { tone: "emerald" as const, title: "会话完成" }; + case "error": + case "failed": + return { tone: "rose" as const, title: "异常事件" }; + case "accepted": + case "delivered": + case "claimed": + case "starting": + case "started": + case "progress": + return { tone: "violet" as const, title: "调度推进" }; + case "cancelled": + return { tone: "amber" as const, title: "任务取消" }; + default: + return { tone: "slate" as const, title: "实时事件" }; + } +} + +function getToolName(event: ChatEvent) { + if ("name" in event && typeof event.name === "string" && event.name.trim()) { + return event.name.trim(); + } + if ("id" in event && typeof event.id === "string" && event.id.trim()) { + return event.id.trim(); + } + return "system tool"; +} + +function summarizeChatEvent(event: ChatEvent): DashboardEvent | null { + const type = String(event.type); + const classified = classifyChatEvent(event); + let detail = ""; + + if (type === "token") { + detail = "模型正在输出 token chunk。"; + } else if (type === "thinking") { + const text = "text" in event && typeof event.text === "string" ? event.text.trim() : ""; + detail = text ? truncateMiddle(text.replace(/\s+/g, " "), 80) : "模型正在整理推理上下文。"; + } else if (type === "tool_call") { + detail = `${getToolName(event)} 已发起调用。`; + } else if (type === "tool_result") { + const isError = "isError" in event && event.isError === true; + detail = `${getToolName(event)} ${isError ? "返回异常" : "返回结果"}。`; + } else if (type === "hosted_search") { + const queries = "queries" in event && Array.isArray(event.queries) ? event.queries : []; + detail = queries.length ? truncateMiddle(queries.join(" / "), 80) : "联网检索通道产生事件。"; + } else if (type === "tool_status") { + const statusText = "status" in event && typeof event.status === "string" ? event.status.trim() : ""; + detail = statusText || "工具链状态更新。"; + } else if (type === "error" || type === "failed") { + detail = "message" in event && typeof event.message === "string" ? event.message : "执行出现异常。"; + } else if (type === "done" || type === "completed") { + detail = "一段会话工作流完成。"; + } else if ("message" in event && typeof event.message === "string" && event.message.trim()) { + detail = event.message.trim(); + } else { + detail = `事件类型:${type}`; + } + + return { + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + at: Date.now(), + title: classified.title, + detail: truncateMiddle(detail, 112), + tone: classified.tone, + conversationId: event.conversation_id, + workdir: event.workdir, + }; +} + +function addPendingCounters(target: PendingCounters, event: ChatEvent) { + const type = String(event.type); + target.events += 1; + if (type === "token") { + target.tokenChunks += 1; + const text = "text" in event && typeof event.text === "string" ? event.text : ""; + target.tokenChars += text.length; + } else if (type === "thinking") { + target.thinking += 1; + } else if (type === "tool_call") { + target.toolCalls += 1; + } else if (type === "tool_result") { + target.toolResults += 1; + } else if (type === "hosted_search") { + target.searches += 1; + } else if (type === "done" || type === "completed") { + target.completions += 1; + } else if (type === "error" || type === "failed") { + target.errors += 1; + } +} + +function useNow(tickMs = 1000) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), tickMs); + return () => window.clearInterval(timer); + }, [tickMs]); + return now; +} + +function StatusPill({ online, label }: { online: boolean; label: string }) { + return ( + + + {label} + + ); +} + +function MetricTile({ metric }: { metric: MetricCard }) { + const Icon = metric.icon; + return ( +
+
+ +
+
+

{metric.label}

+ {metric.value} + {metric.unit} + {metric.detail} +
+
+ ); +} + +function EmptyState({ children }: { children: string }) { + return
{children}
; +} + +function FactList({ items }: { items: FactItem[] }) { + return ( +
+ {items.map((item) => ( +
+ {item.label} + {item.value} + {item.unit && {item.unit}} + {item.note && {item.note}} +
+ ))} +
+ ); +} + +function runSnapshotRequest(promise: Promise) { + return promise.then( + (value) => ({ ok: true as const, value }), + (error) => ({ ok: false as const, error }), + ); +} + +function useDashboardAuth() { + const initialTokenRef = useRef(readDashboardTokenSeed()); + const [token, setToken] = useState(""); + const [loginToken, setLoginToken] = useState(initialTokenRef.current); + const [authSubmitting, setAuthSubmitting] = useState(() => initialTokenRef.current !== ""); + const [authError, setAuthError] = useState(null); + + useEffect(() => { + const seed = initialTokenRef.current; + if (!seed) { + return; + } + let cancelled = false; + setAuthSubmitting(true); + verifyGatewayAccessToken(seed) + .then((verifiedToken) => { + if (cancelled) { + return; + } + saveToken(verifiedToken); + stripDashboardTokenFromUrl(); + setToken(verifiedToken); + setLoginToken(verifiedToken); + setAuthError(null); + }) + .catch((error) => { + if (cancelled) { + return; + } + clearToken(); + stripDashboardTokenFromUrl(); + setAuthError(asErrorMessage(error, "Access Token 验证失败。")); + }) + .finally(() => { + if (!cancelled) { + setAuthSubmitting(false); + } + }); + return () => { + cancelled = true; + }; + }, []); + + const submit = () => { + setAuthSubmitting(true); + setAuthError(null); + verifyGatewayAccessToken(loginToken) + .then((verifiedToken) => { + saveToken(verifiedToken); + setToken(verifiedToken); + setLoginToken(verifiedToken); + }) + .catch((error) => { + setAuthError(asErrorMessage(error, "Access Token 验证失败。")); + }) + .finally(() => setAuthSubmitting(false)); + }; + + const logout = () => { + clearToken(); + resetGatewayWebSocketClient(); + setToken(""); + setLoginToken(""); + setAuthError(null); + setAuthSubmitting(false); + }; + + return { + token, + loginToken, + authSubmitting, + authError, + setLoginToken, + setAuthError, + submit, + logout, + }; +} + +export function StatusDashboardPage() { + const now = useNow(); + const { token, loginToken, authSubmitting, authError, setLoginToken, setAuthError, submit, logout } = + useDashboardAuth(); + const api = useMemo(() => (token ? getGatewayWebSocketClient(token) : null), [token]); + const pendingEventsRef = useRef([]); + const pendingCountersRef = useRef(initialPendingCounters()); + const lastTokenEventAtRef = useRef(0); + + const [status, setStatus] = useState(null); + const [statusError, setStatusError] = useState(null); + const [history, setHistory] = useState(null); + const [workdirs, setWorkdirs] = useState([]); + const [tunnels, setTunnels] = useState([]); + const [terminals, setTerminals] = useState([]); + const [providers, setProviders] = useState([]); + const [settingsSnapshot, setSettingsSnapshot] = useState(null); + const [recentEvents, setRecentEvents] = useState([]); + const [liveCounters, setLiveCounters] = useState(() => initialCounters()); + const [snapshot, setSnapshot] = useState({ + loading: false, + error: null, + lastRefreshAt: 0, + }); + const [refreshVersion, setRefreshVersion] = useState(0); + + useEffect(() => { + const timer = window.setInterval(() => { + const nextEvents = pendingEventsRef.current.splice(0, pendingEventsRef.current.length); + const pendingCounters = pendingCountersRef.current; + pendingCountersRef.current = initialPendingCounters(); + + if (nextEvents.length > 0) { + setRecentEvents((current) => [...nextEvents.reverse(), ...current].slice(0, MAX_RECENT_EVENTS)); + } + if (pendingCounters.events > 0) { + setLiveCounters((current) => ({ + ...current, + events: current.events + pendingCounters.events, + tokenChunks: current.tokenChunks + pendingCounters.tokenChunks, + tokenChars: current.tokenChars + pendingCounters.tokenChars, + thinking: current.thinking + pendingCounters.thinking, + toolCalls: current.toolCalls + pendingCounters.toolCalls, + toolResults: current.toolResults + pendingCounters.toolResults, + searches: current.searches + pendingCounters.searches, + completions: current.completions + pendingCounters.completions, + errors: current.errors + pendingCounters.errors, + })); + } + }, LIVE_FLUSH_MS); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + if (!api) { + return; + } + const unsubscribeStatus = api.subscribeStatus((nextStatus, error) => { + setStatus(nextStatus); + setStatusError(error); + }); + const unsubscribeHistory = api.subscribeHistory((event) => { + setHistory((current) => updateHistoryListWithEvent(current, event)); + }); + const unsubscribeConversation = api.subscribeConversation((event) => { + addPendingCounters(pendingCountersRef.current, event); + if (event.type === "token") { + const currentAt = Date.now(); + if (currentAt - lastTokenEventAtRef.current < TOKEN_EVENT_MIN_INTERVAL_MS) { + return; + } + lastTokenEventAtRef.current = currentAt; + } + const summary = summarizeChatEvent(event); + if (summary) { + pendingEventsRef.current.push(summary); + } + }); + const unsubscribeTerminal = api.subscribeTerminal((event) => { + if (event.session) { + const session = event.session; + setTerminals((current) => { + const without = current.filter((item) => item.id !== session.id); + return [session, ...without].sort((a, b) => b.updatedAt - a.updatedAt); + }); + } + }); + const unsubscribeSettings = api.subscribeSettings((payload) => { + setSettingsSnapshot(payload); + }); + + return () => { + unsubscribeStatus(); + unsubscribeHistory(); + unsubscribeConversation(); + unsubscribeTerminal(); + unsubscribeSettings(); + }; + }, [api]); + + useEffect(() => { + if (!api) { + return; + } + let cancelled = false; + async function refresh(currentApi: GatewayWebSocketClientLike) { + setSnapshot((current) => ({ ...current, loading: true, error: null })); + const [ + statusResult, + historyResult, + workdirsResult, + tunnelsResult, + terminalsResult, + providersResult, + settingsResult, + ] = await Promise.all([ + runSnapshotRequest(currentApi.getStatus()), + runSnapshotRequest(currentApi.listHistory(1, HISTORY_PAGE_SIZE)), + runSnapshotRequest(currentApi.listHistoryWorkdirs()), + runSnapshotRequest(currentApi.listTunnels()), + runSnapshotRequest(currentApi.listTerminals()), + runSnapshotRequest(currentApi.listProviders()), + runSnapshotRequest(currentApi.getSettings()), + ]); + if (cancelled) { + return; + } + const errors: string[] = []; + if (statusResult.ok) { + setStatus(statusResult.value); + setStatusError(null); + } else { + errors.push(asErrorMessage(statusResult.error, "状态读取失败")); + } + if (historyResult.ok) { + setHistory(historyResult.value); + } else { + errors.push(asErrorMessage(historyResult.error, "历史读取失败")); + } + if (workdirsResult.ok) { + setWorkdirs(workdirsResult.value.workdirs); + } else { + errors.push(asErrorMessage(workdirsResult.error, "项目活动读取失败")); + } + if (tunnelsResult.ok) { + setTunnels(tunnelsResult.value); + } else { + errors.push(asErrorMessage(tunnelsResult.error, "公网通道读取失败")); + } + if (terminalsResult.ok) { + setTerminals(terminalsResult.value); + } else { + errors.push(asErrorMessage(terminalsResult.error, "终端读取失败")); + } + if (providersResult.ok) { + setProviders(providersResult.value); + } else { + errors.push(asErrorMessage(providersResult.error, "模型源读取失败")); + } + if (settingsResult.ok) { + setSettingsSnapshot(settingsResult.value); + } else { + errors.push(asErrorMessage(settingsResult.error, "设置读取失败")); + } + setSnapshot({ + loading: false, + error: errors.length > 0 ? Array.from(new Set(errors)).slice(0, 2).join(" / ") : null, + lastRefreshAt: Date.now(), + }); + } + + void refresh(api); + const timer = window.setInterval(() => void refresh(api), SNAPSHOT_REFRESH_MS); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [api, refreshVersion]); + + const runningConversations = useMemo(() => buildRunningConversations(history), [history]); + const activeTunnels = useMemo( + () => tunnels.filter((item) => item.status === "active" && (!item.expiresAt || item.expiresAt > now)), + [now, tunnels], + ); + const runningTerminals = useMemo(() => terminals.filter((item) => item.running), [terminals]); + const activeProviders = useMemo( + () => providers.filter((provider) => provider.activeModels.length > 0), + [providers], + ); + const activeModelCount = useMemo( + () => activeProviders.reduce((total, provider) => total + provider.activeModels.length, 0), + [activeProviders], + ); + const uptimeMs = status?.online ? now - normalizeEpochMs(status.connected_since) : 0; + const heartbeatAgeMs = status?.last_heartbeat ? now - normalizeEpochMs(status.last_heartbeat) : 0; + const isFreshHeartbeat = status?.online === true && heartbeatAgeMs < 20_000; + const observedMinutes = Math.max(1, (now - liveCounters.startedAt) / 60_000); + const eventsPerMinute = liveCounters.events / observedMinutes; + const messageSampleCount = useMemo( + () => (history?.conversations ?? []).reduce((total, item) => total + (item.message_count || 0), 0), + [history], + ); + const todayConversationCount = useMemo(() => { + const start = new Date(now); + start.setHours(0, 0, 0, 0); + return (history?.conversations ?? []).filter((item) => normalizeEpochMs(item.created_at) >= start.getTime()).length; + }, [history, now]); + const runtimeState = formatRuntimeState(status); + const runtimeHeartbeatAgeMs = status?.runtime_last_heartbeat ? now - normalizeEpochMs(status.runtime_last_heartbeat) : 0; + const runtimeActiveRunCount = status?.runtime_active_run_count ?? runningConversations.length; + const totalTunnelConnections = activeTunnels.reduce((sum, item) => sum + item.activeConnections, 0); + const activeWorkspaceProjects = settingsSnapshot?.system.workspaceProjects ?? []; + const activeWorkspaceProject = + activeWorkspaceProjects.find((project) => project.id === settingsSnapshot?.system.activeWorkspaceProjectId) ?? + activeWorkspaceProjects[0]; + const selectedModel = settingsSnapshot?.selectedModel ?? null; + const selectedProvider = selectedModel + ? providers.find((provider) => provider.id === selectedModel.customProviderId) ?? + settingsSnapshot?.customProviders.find((provider) => provider.id === selectedModel.customProviderId) + : undefined; + const selectedProviderName = selectedProvider?.name?.trim() || selectedModel?.customProviderId || "--"; + const selectedProviderType = selectedProvider?.type || "--"; + const selectedModelConfig = selectedProvider?.models?.find((model) => model.id === selectedModel?.model); + const enabledCronCount = settingsSnapshot?.cron.filter((task) => task.enabled).length ?? 0; + const enabledHookCount = settingsSnapshot?.hooks.filter((hook) => hook.enabled).length ?? 0; + const enabledMcpCount = settingsSnapshot?.mcp.servers.filter((server) => server.enabled).length ?? 0; + const configuredProviderCount = settingsSnapshot?.customProviders.filter((provider) => provider.apiKeyConfigured).length ?? 0; + const selectedSkillCount = settingsSnapshot?.skills.enabled ? settingsSnapshot.skills.selected.length : 0; + const remoteFeatureCount = settingsSnapshot?.remote + ? [ + settingsSnapshot.remote.enableWebTerminal, + settingsSnapshot.remote.enableWebGit, + settingsSnapshot.remote.enableWebTunnels, + ].filter(Boolean).length + : 0; + const latestTerminal = runningTerminals[0] ?? terminals[0]; + const activeWorkspaceName = + activeWorkspaceProject?.name?.trim() || (activeWorkspaceProject?.path ? basename(activeWorkspaceProject.path) : "--"); + const activeWorkspaceHint = activeWorkspaceProject?.path + ? truncateMiddle(activeWorkspaceProject.path, 48) + : settingsSnapshot + ? "未配置工作区" + : "等待 settings.get"; + const loadedConversationRows = history?.conversations.length ?? 0; + const maxWorkdirCount = Math.max(1, ...workdirs.map((item) => item.conversationCount || 0)); + const activeSubsystemCount = + Number(status?.online === true) + + Number(status?.chat_runtime_ready === true) + + Number(activeProviders.length > 0) + + Number(runningTerminals.length > 0) + + Number(activeTunnels.length > 0) + + Number(enabledMcpCount > 0) + + Number(enabledCronCount > 0) + + Number(settingsSnapshot?.skills.enabled === true); + const integrityScore = Math.min( + 100, + (status?.online ? 34 : 0) + + (isFreshHeartbeat ? 18 : 0) + + (status?.chat_runtime_ready ? 18 : 0) + + (settingsSnapshot ? 12 : 0) + + (activeProviders.length > 0 ? 10 : 0) + + (snapshot.error ? 0 : 8), + ); + const activeLoadValues = [ + liveCounters.tokenChunks, + liveCounters.thinking, + liveCounters.toolCalls + liveCounters.toolResults, + liveCounters.searches, + liveCounters.errors, + ]; + const maxLoadValue = Math.max(1, ...activeLoadValues); + const toLoadWidth = (value: number) => Math.max(value > 0 ? 12 : 4, Math.round((value / maxLoadValue) * 100)); + const throughputSegments: LoadSegment[] = [ + { label: "Token Chunks", value: liveCounters.tokenChunks, unit: "chunks", width: toLoadWidth(liveCounters.tokenChunks), tone: "cyan" }, + { label: "Reasoning", value: liveCounters.thinking, unit: "events", width: toLoadWidth(liveCounters.thinking), tone: "violet" }, + { label: "Tool I/O", value: liveCounters.toolCalls + liveCounters.toolResults, unit: "events", width: toLoadWidth(liveCounters.toolCalls + liveCounters.toolResults), tone: "amber" }, + { label: "Web Search", value: liveCounters.searches, unit: "events", width: toLoadWidth(liveCounters.searches), tone: "emerald" }, + { label: "Errors", value: liveCounters.errors, unit: "events", width: toLoadWidth(liveCounters.errors), tone: "rose" }, + ]; + + const runtimeFacts: FactItem[] = [ + { + label: "Runtime State", + value: runtimeState, + note: `chat_runtime_ready=${formatBooleanFlag(status?.chat_runtime_ready)}`, + tone: status?.online ? "emerald" : "rose", + }, + { + label: "Active Runs", + value: String(runtimeActiveRunCount), + unit: "runs", + note: status?.runtime_active_run_count !== undefined ? "runtime_active_run_count" : "history running fallback", + tone: runtimeActiveRunCount > 0 ? "violet" : "slate", + }, + { + label: "Worker ID", + value: status?.runtime_worker_id ? truncateMiddle(status.runtime_worker_id, 22) : "--", + note: `runtime_visible=${formatBooleanFlag(status?.runtime_visible)}`, + }, + { + label: "Runtime Heartbeat Age", + value: status?.runtime_last_heartbeat ? formatDuration(runtimeHeartbeatAgeMs) : "--", + unit: "age", + note: "runtime_last_heartbeat", + }, + ]; + + const modelFacts: FactItem[] = [ + { + label: "Selected Model", + value: selectedModel?.model ? truncateMiddle(selectedModel.model, 30) : "--", + note: selectedProviderName, + tone: selectedModel ? "violet" : "slate", + }, + { + label: "Provider", + value: truncateMiddle(selectedProviderName, 24), + note: selectedProviderType, + }, + { + label: "Context Window", + value: selectedModelConfig?.contextWindow ? compactNumber(selectedModelConfig.contextWindow) : "--", + unit: "tokens", + note: selectedModelConfig?.maxOutputToken ? `${compactNumber(selectedModelConfig.maxOutputToken)} max output tokens` : "model config", + }, + { + label: "Reasoning Mode", + value: settingsSnapshot?.chatRuntimeControls.reasoning ?? "--", + note: `thinking=${formatBooleanFlag(settingsSnapshot?.chatRuntimeControls.thinkingEnabled)} · web_search=${formatBooleanFlag(settingsSnapshot?.chatRuntimeControls.nativeWebSearchEnabled)}`, + }, + ]; + + const fabricFacts: FactItem[] = [ + { + label: "MCP Servers", + value: settingsSnapshot ? `${enabledMcpCount}/${settingsSnapshot.mcp.servers.length}` : "--", + unit: "enabled/total", + note: `selected ${settingsSnapshot?.mcp.selected.length ?? "--"}`, + tone: enabledMcpCount > 0 ? "cyan" : "slate", + }, + { + label: "Cron Tasks", + value: settingsSnapshot ? `${enabledCronCount}/${settingsSnapshot.cron.length}` : "--", + unit: "enabled/total", + tone: enabledCronCount > 0 ? "amber" : "slate", + }, + { + label: "Hooks", + value: settingsSnapshot ? `${enabledHookCount}/${settingsSnapshot.hooks.length}` : "--", + unit: "enabled/total", + }, + { + label: "Skills", + value: settingsSnapshot?.skills.enabled ? String(selectedSkillCount) : "OFF", + unit: settingsSnapshot?.skills.enabled ? "selected" : undefined, + note: `skills.enabled=${formatBooleanFlag(settingsSnapshot?.skills.enabled)}`, + }, + ]; + + const telemetryFacts: FactItem[] = [ + { + label: "Derived Integrity", + value: String(integrityScore), + unit: "%", + note: `${activeSubsystemCount}/8 observed subsystems`, + tone: integrityScore >= 70 ? "emerald" : integrityScore >= 40 ? "amber" : "rose", + }, + { + label: "Event Rate", + value: eventsPerMinute.toFixed(1), + unit: "events/min", + note: "live WebSocket events since page open", + tone: liveCounters.events > 0 ? "cyan" : "slate", + }, + { + label: "Text Output", + value: compactNumber(liveCounters.tokenChars), + unit: "chars", + note: `${compactNumber(liveCounters.tokenChunks)} token chunks`, + tone: "violet", + }, + { + label: "Tool Traffic", + value: compactNumber(liveCounters.toolCalls + liveCounters.toolResults), + unit: "events", + note: `${compactNumber(liveCounters.errors)} error events`, + tone: liveCounters.errors > 0 ? "rose" : "amber", + }, + ]; + + const metrics: MetricCard[] = [ + { + label: "Agent Link", + value: status?.online ? (isFreshHeartbeat ? "LIVE" : "WARM") : "OFFLINE", + unit: "state", + detail: status?.online ? `uptime ${formatDuration(uptimeMs)} · heartbeat age ${formatDuration(heartbeatAgeMs)}` : statusError || "desktop agent not connected", + tone: status?.online ? "emerald" : "rose", + icon: status?.online ? Wifi : WifiOff, + }, + { + label: "Runtime Runs", + value: String(runtimeActiveRunCount), + unit: "runs", + detail: `${runningConversations.length} running conversations in history snapshot`, + tone: runtimeActiveRunCount > 0 ? "violet" : "slate", + icon: Radio, + }, + { + label: "History Index", + value: compactNumber(history?.total_count ?? 0), + unit: "conversations", + detail: `loaded ${loadedConversationRows} rows · today sample ${todayConversationCount} · ${compactNumber(messageSampleCount)} msgs in loaded rows`, + tone: "cyan", + icon: History, + }, + { + label: "Public Tunnels", + value: String(activeTunnels.length), + unit: "active", + detail: `${tunnels.length} tunnel records · ${totalTunnelConnections} active connections`, + tone: activeTunnels.length > 0 ? "amber" : "slate", + icon: Cloud, + }, + { + label: "Web Terminals", + value: String(runningTerminals.length), + unit: "running sessions", + detail: `${terminals.length} total sessions · ${latestTerminal ? basename(latestTerminal.cwd) : "no cwd"}`, + tone: runningTerminals.length > 0 ? "emerald" : "slate", + icon: Terminal, + }, + { + label: "Active Models", + value: String(activeModelCount), + unit: "models", + detail: `${activeProviders.length} active providers · ${configuredProviderCount} provider keys configured`, + tone: "violet", + icon: Sparkles, + }, + ]; + + if (!token) { + return ( + { + setLoginToken(nextToken); + if (authError) { + setAuthError(null); + } + }} + onSubmit={submit} + /> + ); + } + + return ( +
+
+ ); +} diff --git a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx index 5ecd963d5..79a246cdf 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx @@ -6,7 +6,16 @@ import { type MutableRefObject, type ReactNode, } from "react"; -import { Brain, Globe2, Lightbulb, Loader2, Paperclip, Send, Square, X } from "../../components/icons"; +import { + Brain, + Globe2, + Lightbulb, + Loader2, + Paperclip, + Send, + Square, + X, +} from "../../components/icons"; import { MentionComposer, @@ -75,6 +84,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onGitChanged?: (workdir: string) => void; onSend: () => void; onStop: () => void; + onPrepareChatRuntime?: () => void; onComposerBusyChange: (isBusy: boolean) => void; onChatRuntimeControlsChange: (patch: Partial) => void; onPickReadableFiles: () => void; @@ -99,6 +109,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { onGitChanged, onSend, onStop, + onPrepareChatRuntime, onComposerBusyChange, onChatRuntimeControlsChange, onPickReadableFiles, @@ -232,7 +243,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { className="pointer-events-none absolute inset-0 rounded-[24px] bg-gradient-to-b from-white/30 to-transparent opacity-60 dark:from-white/[0.04] dark:opacity-100" /> -
+
+
+
+
{t("settings.remoteWebTunnels")}
+

+ {t("settings.remoteWebTunnelsHint")} +

+
+ + {settings.remote.enableWebTunnels + ? t("settings.cronViewStatusEnabled") + : t("settings.cronViewStatusDisabled")} + +
+
-
+
, + document.body, ); } diff --git a/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx b/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx new file mode 100644 index 000000000..1679396d5 --- /dev/null +++ b/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx @@ -0,0 +1,974 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useLocale } from "../../i18n"; +import { cn } from "../../lib/shared/utils"; +import { + AlertTriangle, + Check, + ChevronDown, + Clock3, + Copy, + Edit3, + ExternalLink, + Folder, + Globe, + Link2, + Loader2, + Plus, + Trash2, + X, +} from "../icons"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; + +export type TunnelTtlSeconds = 0 | 900 | 3600 | 14400; + +export type TunnelCreateInput = { + targetUrl: string; + name?: string; + ttlSeconds: TunnelTtlSeconds; + projectPathKey?: string; +}; + +export type TunnelUpdateInput = { + id: string; + targetUrl: string; + name?: string; + ttlSeconds: TunnelTtlSeconds; + projectPathKey?: string; +}; + +export type TunnelSummary = { + id: string; + slug: string; + name: string; + targetUrl: string; + publicUrl: string; + createdAt: number; + expiresAt: number; + status: "active" | "expired" | "offline"; + projectPathKey?: string; +}; + +export type LocalTunnelClient = { + listTunnels(): Promise; + createTunnel(input: TunnelCreateInput): Promise; + updateTunnel(input: TunnelUpdateInput): Promise; + closeTunnel(id: string): Promise; +}; + +type LocalTunnelPanelProps = { + client: LocalTunnelClient; + enabled?: boolean; + disabledMessage?: string; + projectPathKey?: string; + refreshToken?: number; +}; + +type TunnelScope = "project" | "global"; + +const TUNNEL_MANAGER_CHANGED_EVENT = "liveagent:tunnel-manager-changed"; + +const TUNNEL_SCOPE_OPTIONS: Array<{ + scope: TunnelScope; + labelKey: string; + titleKey: string; +}> = [ + { + scope: "project", + labelKey: "projectTools.tunnelScopeProject", + titleKey: "projectTools.tunnelScopeProjectTitle", + }, + { + scope: "global", + labelKey: "projectTools.tunnelScopeGlobal", + titleKey: "projectTools.tunnelScopeGlobalTitle", + }, +]; + +const TTL_OPTIONS: Array<{ value: TunnelTtlSeconds; labelKey: string }> = [ + { value: 900, labelKey: "projectTools.tunnelTtl15m" }, + { value: 3600, labelKey: "projectTools.tunnelTtl1h" }, + { value: 14400, labelKey: "projectTools.tunnelTtl4h" }, + { value: 0, labelKey: "projectTools.tunnelTtlInfinite" }, +]; + +const TUNNEL_INPUT_CLASS = + "h-8 min-w-0 rounded-lg border-border/60 bg-background/80 text-xs transition-[border-color,box-shadow,background-color] focus-visible:border-muted-foreground/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-muted-foreground/15 focus-visible:ring-offset-0"; + +function TtlSegmented({ + value, + onChange, + disabled, +}: { + value: TunnelTtlSeconds; + onChange: (value: TunnelTtlSeconds) => void; + disabled?: boolean; +}) { + const { t } = useLocale(); + return ( +
+ {TTL_OPTIONS.map((option) => { + const active = value === option.value; + return ( + + ); + })} +
+ ); +} + +function validateLocalHttpTarget(input: string) { + const value = input.trim(); + if (!value) return "projectTools.tunnelTargetRequired"; + try { + const url = new URL(value); + if (url.protocol !== "http:") { + return "projectTools.tunnelInvalidUrl"; + } + const hostname = url.hostname.toLowerCase(); + if (!["localhost", "127.0.0.1", "::1", "[::1]"].includes(hostname)) { + return "projectTools.tunnelLocalhostOnly"; + } + if (url.username || url.password || url.hash) { + return "projectTools.tunnelInvalidUrl"; + } + } catch { + return "projectTools.tunnelInvalidUrl"; + } + return null; +} + +function asErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} + +function formatRemaining(seconds: number) { + if (seconds <= 0) return "0m"; + const hours = Math.floor(seconds / 3600); + const minutes = Math.ceil((seconds % 3600) / 60); + if (hours <= 0) return `${minutes}m`; + if (minutes >= 60) return `${hours + 1}h`; + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +} + +function formatDateTime(seconds: number) { + if (!seconds) return ""; + return new Date(seconds * 1000).toLocaleString(); +} + +function writeTextToClipboard(text: string) { + if (navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(text).then( + () => true, + () => fallbackWriteTextToClipboard(text), + ); + } + return Promise.resolve(fallbackWriteTextToClipboard(text)); +} + +function fallbackWriteTextToClipboard(text: string) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "-9999px"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + try { + return document.execCommand("copy"); + } finally { + document.body.removeChild(textarea); + } +} + +function displayTunnelName(tunnel: TunnelSummary) { + return tunnel.name.trim() || tunnel.targetUrl; +} + +function tunnelStatusKey(status: TunnelSummary["status"]) { + if (status === "expired") return "projectTools.tunnelStatusExpired"; + if (status === "offline") return "projectTools.tunnelStatusOffline"; + return "projectTools.tunnelStatusActive"; +} + +function normalizeProjectPathKey(value: string | undefined) { + return value?.trim() ?? ""; +} + +function ttlFromTunnel(tunnel: TunnelSummary, nowSeconds: number): TunnelTtlSeconds { + if (!tunnel.expiresAt) return 0; + const remaining = Math.max(0, tunnel.expiresAt - nowSeconds); + if (remaining <= 900) return 900; + if (remaining <= 3600) return 3600; + return 14400; +} + +export function LocalTunnelPanel({ + client, + enabled = true, + disabledMessage, + projectPathKey, + refreshToken, +}: LocalTunnelPanelProps) { + const { t } = useLocale(); + const normalizedProjectPathKey = useMemo( + () => normalizeProjectPathKey(projectPathKey), + [projectPathKey], + ); + const [scope, setScope] = useState(() => + normalizeProjectPathKey(projectPathKey) ? "project" : "global", + ); + const [targetUrl, setTargetUrl] = useState("http://localhost:3000"); + const [name, setName] = useState(""); + const [ttlSeconds, setTtlSeconds] = useState(3600); + const [createOpen, setCreateOpen] = useState(true); + const [editingId, setEditingId] = useState(""); + const [editTargetUrl, setEditTargetUrl] = useState(""); + const [editName, setEditName] = useState(""); + const [editTtlSeconds, setEditTtlSeconds] = useState(3600); + const [tunnels, setTunnels] = useState([]); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [savingId, setSavingId] = useState(""); + const [closingId, setClosingId] = useState(""); + const [copiedId, setCopiedId] = useState(""); + const [error, setError] = useState(null); + const [nowSeconds, setNowSeconds] = useState(() => Math.floor(Date.now() / 1000)); + const refreshTokenRef = useRef(refreshToken); + const targetValidationKey = useMemo(() => validateLocalHttpTarget(targetUrl), [targetUrl]); + const editTargetValidationKey = useMemo( + () => (editingId ? validateLocalHttpTarget(editTargetUrl) : null), + [editTargetUrl, editingId], + ); + + const refresh = useCallback( + (options?: { showLoading?: boolean }) => { + const showLoading = options?.showLoading ?? true; + if (showLoading) { + setLoading(true); + } + setError(null); + return client + .listTunnels() + .then((items) => setTunnels(items)) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => { + if (showLoading) { + setLoading(false); + } + }); + }, + [client], + ); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (refreshTokenRef.current === refreshToken) return; + refreshTokenRef.current = refreshToken; + void refresh({ showLoading: false }); + }, [refresh, refreshToken]); + + useEffect(() => { + const handleTunnelManagerChanged = () => { + void refresh({ showLoading: false }); + }; + window.addEventListener(TUNNEL_MANAGER_CHANGED_EVENT, handleTunnelManagerChanged); + return () => + window.removeEventListener(TUNNEL_MANAGER_CHANGED_EVENT, handleTunnelManagerChanged); + }, [refresh]); + + useEffect(() => { + if (!normalizedProjectPathKey && scope === "project") { + setScope("global"); + setError(null); + } + }, [normalizedProjectPathKey, scope]); + + useEffect(() => { + const timer = window.setInterval(() => { + setNowSeconds(Math.floor(Date.now() / 1000)); + }, 1000); + return () => window.clearInterval(timer); + }, []); + + useEffect(() => { + if (!copiedId) return; + const timer = window.setTimeout(() => setCopiedId(""), 1600); + return () => window.clearTimeout(timer); + }, [copiedId]); + + const createTunnel = useCallback(() => { + const validationKey = validateLocalHttpTarget(targetUrl); + if (validationKey) { + setError(t(validationKey)); + return; + } + if (!enabled || creating) return; + const input: TunnelCreateInput = { + targetUrl: targetUrl.trim(), + name: name.trim() || undefined, + ttlSeconds, + }; + if (scope === "project" && normalizedProjectPathKey) { + input.projectPathKey = normalizedProjectPathKey; + } + setCreating(true); + setError(null); + void client + .createTunnel(input) + .then((created) => { + setTunnels((current) => [ + created, + ...current.filter((item) => item.id !== created.id && item.slug !== created.slug), + ]); + setName(""); + void refresh({ showLoading: false }); + }) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => setCreating(false)); + }, [ + client, + creating, + enabled, + name, + normalizedProjectPathKey, + refresh, + scope, + t, + targetUrl, + ttlSeconds, + ]); + + const beginEdit = useCallback( + (tunnel: TunnelSummary) => { + setEditingId(tunnel.id); + setEditTargetUrl(tunnel.targetUrl); + setEditName(tunnel.name); + setEditTtlSeconds(ttlFromTunnel(tunnel, nowSeconds)); + setError(null); + }, + [nowSeconds], + ); + + const cancelEdit = useCallback(() => { + setEditingId(""); + setEditTargetUrl(""); + setEditName(""); + setEditTtlSeconds(3600); + setError(null); + }, []); + + const updateTunnel = useCallback( + (tunnel: TunnelSummary) => { + const validationKey = validateLocalHttpTarget(editTargetUrl); + if (validationKey) { + setError(t(validationKey)); + return; + } + if (!enabled || savingId) return; + const input: TunnelUpdateInput = { + id: tunnel.id, + targetUrl: editTargetUrl.trim(), + name: editName.trim() || undefined, + ttlSeconds: editTtlSeconds, + }; + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + if (tunnelProjectPathKey) { + input.projectPathKey = tunnelProjectPathKey; + } + setSavingId(tunnel.id); + setError(null); + void client + .updateTunnel(input) + .then((updated) => { + setTunnels((current) => current.map((item) => (item.id === updated.id ? updated : item))); + cancelEdit(); + }) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => setSavingId((current) => (current === tunnel.id ? "" : current))); + }, + [cancelEdit, client, editName, editTargetUrl, editTtlSeconds, enabled, savingId, t], + ); + + const closeTunnel = useCallback( + (id: string) => { + if (!enabled || closingId) return; + setClosingId(id); + setError(null); + void client + .closeTunnel(id) + .then((closed) => { + setTunnels((current) => + current + .filter((item) => item.id !== id) + .concat(closed.status === "active" ? [closed] : []), + ); + }) + .catch((err) => setError(asErrorMessage(err))) + .finally(() => setClosingId((current) => (current === id ? "" : current))); + }, + [client, closingId, enabled], + ); + + const copyLink = useCallback((tunnel: TunnelSummary) => { + if (!tunnel.publicUrl) return; + void writeTextToClipboard(tunnel.publicUrl) + .then((copied) => { + if (copied) { + setCopiedId(tunnel.id); + } + }) + .catch(() => {}); + }, []); + + const openLink = useCallback((tunnel: TunnelSummary) => { + if (!tunnel.publicUrl) return; + setError(null); + void openUrl(tunnel.publicUrl).catch((err) => setError(asErrorMessage(err))); + }, []); + + const scopedTunnels = useMemo( + () => + tunnels.filter((tunnel) => { + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + if (scope === "project") { + return ( + Boolean(normalizedProjectPathKey) && tunnelProjectPathKey === normalizedProjectPathKey + ); + } + return true; + }), + [normalizedProjectPathKey, scope, tunnels], + ); + const sortedTunnels = useMemo( + () => [...scopedTunnels].sort((a, b) => b.createdAt - a.createdAt), + [scopedTunnels], + ); + const canCreate = + enabled && + !creating && + !targetValidationKey && + (scope !== "project" || Boolean(normalizedProjectPathKey)); + const showCreateForm = scope === "project" && Boolean(normalizedProjectPathKey); + const createFieldsDisabled = !showCreateForm || !createOpen || !enabled || creating; + + return ( +
+
+
+
+ +
+
+
+ {t("projectTools.tunnelTitle")} +
+
+ {t("projectTools.tunnelDescription")} +
+
+
+
+
+ {TUNNEL_SCOPE_OPTIONS.map((option) => { + const active = scope === option.scope; + const disabled = option.scope === "project" && !normalizedProjectPathKey; + const Icon = option.scope === "project" ? Folder : Globe; + return ( + + ); + })} +
+
+ +
+ {disabledMessage ? ( +
+ + {disabledMessage} +
+ ) : null} + + {normalizedProjectPathKey ? ( +
+
+
+ +
+
+
{ + event.preventDefault(); + if (!showCreateForm || !createOpen) return; + createTunnel(); + }} + > +
+ + setTargetUrl(event.target.value)} + placeholder={t("projectTools.tunnelTargetPlaceholder")} + disabled={createFieldsDisabled} + inputMode="url" + autoComplete="off" + spellCheck={false} + className={cn(TUNNEL_INPUT_CLASS, "font-mono")} + /> + {targetValidationKey ? ( +
+ + {t(targetValidationKey)} +
+ ) : null} +
+
+ + setName(event.target.value)} + placeholder={t("projectTools.tunnelNamePlaceholder")} + disabled={createFieldsDisabled} + autoComplete="off" + className={TUNNEL_INPUT_CLASS} + /> +
+
+ + +
+ +
+
+
+
+
+
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
+
+ + {t("projectTools.tunnelListSection")} + + {sortedTunnels.length > 0 ? ( + + {sortedTunnels.length} + + ) : null} +
+ {loading && sortedTunnels.length === 0 ? ( +
+ {t("projectTools.tunnelLoading")} +
+
+
+ ) : sortedTunnels.length === 0 ? ( +
+
+ +
+
+ {t("projectTools.tunnelEmpty")} +
+ {showCreateForm ? ( +
+ {t("projectTools.tunnelEmptyHintCreate")} +
+ ) : normalizedProjectPathKey ? ( +
+ {t("projectTools.tunnelEmptyHintProject")} +
+ ) : null} +
+ ) : ( +
+ {sortedTunnels.map((tunnel) => { + const hasExpiry = tunnel.expiresAt > 0; + const remaining = hasExpiry ? tunnel.expiresAt - nowSeconds : 0; + const expired = tunnel.status === "expired" || (hasExpiry && remaining <= 0); + const isEditing = editingId === tunnel.id; + const updating = savingId === tunnel.id; + const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); + const handleEditKeyDown = (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") { + event.preventDefault(); + updateTunnel(tunnel); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelEdit(); + } + }; + return ( +
+
+
+ {displayTunnelName(tunnel)} +
+ + + {t(tunnelStatusKey(expired ? "expired" : tunnel.status))} + +
+ + {isEditing ? ( + <> +
+
+ + setEditTargetUrl(event.target.value)} + onKeyDown={handleEditKeyDown} + disabled={!enabled || updating} + inputMode="url" + autoComplete="off" + spellCheck={false} + className={cn(TUNNEL_INPUT_CLASS, "font-mono")} + /> + {editTargetValidationKey ? ( +
+ + {t(editTargetValidationKey)} +
+ ) : null} +
+
+ + setEditName(event.target.value)} + onKeyDown={handleEditKeyDown} + placeholder={t("projectTools.tunnelNamePlaceholder")} + disabled={!enabled || updating} + autoComplete="off" + className={TUNNEL_INPUT_CLASS} + /> +
+
+ + +
+
+
+ + +
+ + ) : ( + <> + +
+ + {t("projectTools.tunnelTarget")} + {tunnel.targetUrl} +
+
+
+ + + + {!hasExpiry + ? t("projectTools.tunnelTtlInfinite") + : expired + ? t("projectTools.tunnelExpired") + : t("projectTools.tunnelExpiresIn").replace( + "{time}", + formatRemaining(remaining), + )} + + + {scope === "global" ? ( + + {t( + tunnelProjectPathKey + ? "projectTools.tunnelScopeProjectBadge" + : "projectTools.tunnelScopeGlobalBadge", + )} + + ) : null} +
+
+ + + +
+
+ + )} +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx b/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx index 1a816234e..0e9b8a05e 100644 --- a/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/ProjectToolsPanel.tsx @@ -33,6 +33,7 @@ import { ChevronRight, FolderTree, GitBranch, + Globe, GripVertical, Plus, Terminal, @@ -53,6 +54,7 @@ import { type GitCommitContextPayload, type GitFileContextPayload, } from "./GitReviewPanel"; +import { LocalTunnelPanel, type LocalTunnelClient } from "./LocalTunnelPanel"; import { ProjectFileTreePanel } from "./ProjectFileTreePanel"; const MIN_PANEL_WIDTH = 320; @@ -63,6 +65,7 @@ const DEFAULT_TERMINAL_COLS = 80; const DEFAULT_TERMINAL_ROWS = 24; const FILE_TREE_TAB_ID = "__file_tree__"; const GIT_REVIEW_TAB_ID = "__git_review__"; +const TUNNEL_TAB_ID = "__tunnel__"; const PROJECT_TOOLS_RESIZE_END_EVENT = "liveagent:project-tools-resize-end"; type ProjectToolsPanelProps = { @@ -80,16 +83,22 @@ type ProjectToolsPanelProps = { fileTreeOpen: boolean; fileTreeState: ProjectToolsFileTreeProjectState; gitReviewOpen: boolean; + tunnelOpen?: boolean; client: TerminalClient; gitClient?: GitClient | null; gitWriteEnabled?: boolean; gitDisabledMessage?: string; + tunnelClient?: LocalTunnelClient | null; + tunnelEnabled?: boolean; + tunnelDisabledMessage?: string; + tunnelRefreshToken?: number; onWidthChange: (width: number) => void; onActiveTabChange: (tab: ProjectToolsPanelTab) => void; onTabOrderChange?: (tabOrder: string[]) => void; onFileTreeOpenChange: (open: boolean) => void; onFileTreeStateChange: (patch: ProjectToolsFileTreeStatePatch) => void; onGitReviewOpenChange: (open: boolean) => void; + onTunnelOpenChange?: (open: boolean) => void; onSessionsChange?: (sessions: TerminalSession[]) => void; onInsertFileMention?: (path: string, kind: "file" | "dir") => void; onOpenFile?: (path: string) => void; @@ -199,6 +208,10 @@ type ProjectToolsTab = | { id: typeof GIT_REVIEW_TAB_ID; kind: "gitReview"; + } + | { + id: typeof TUNNEL_TAB_ID; + kind: "tunnel"; }; type TabDragState = { @@ -648,16 +661,22 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { fileTreeOpen, fileTreeState, gitReviewOpen, + tunnelOpen = false, client, gitClient, gitWriteEnabled = true, gitDisabledMessage, + tunnelClient, + tunnelEnabled = true, + tunnelDisabledMessage, + tunnelRefreshToken, onWidthChange, onActiveTabChange, onTabOrderChange, onFileTreeOpenChange, onFileTreeStateChange, onGitReviewOpenChange, + onTunnelOpenChange, onSessionsChange, onInsertFileMention, onOpenFile, @@ -703,14 +722,19 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { const isControlled = externalSessions !== undefined; const fileTreeInitialized = Boolean(projectPathKey && fileTreeOpen); const gitReviewInitialized = Boolean(projectPathKey && gitReviewOpen); + const tunnelInitialized = Boolean(tunnelOpen && tunnelClient); + const tunnelAvailable = Boolean(tunnelClient); const previousFileTreeInitializedRef = useRef(fileTreeInitialized); const previousGitReviewInitializedRef = useRef(gitReviewInitialized); + const previousTunnelInitializedRef = useRef(tunnelInitialized); const currentActiveTab: ProjectToolsPanelTab = - activeTab === "gitReview" && gitReviewInitialized - ? "gitReview" - : activeTab === "fileTree" && fileTreeInitialized - ? "fileTree" - : "terminal"; + activeTab === "tunnel" && tunnelInitialized + ? "tunnel" + : activeTab === "gitReview" && gitReviewInitialized + ? "gitReview" + : activeTab === "fileTree" && fileTreeInitialized + ? "fileTree" + : "terminal"; const activeSession = useMemo( () => sessions.find((session) => session.id === activeSessionId) ?? sessions[0] ?? null, @@ -735,8 +759,11 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { if (gitReviewInitialized) { nextTabs.push({ id: GIT_REVIEW_TAB_ID, kind: "gitReview" }); } + if (tunnelInitialized) { + nextTabs.push({ id: TUNNEL_TAB_ID, kind: "tunnel" }); + } return nextTabs; - }, [fileTreeInitialized, gitReviewInitialized, sessions]); + }, [fileTreeInitialized, gitReviewInitialized, sessions, tunnelInitialized]); const effectiveTabOrder = draftTabOrder ?? tabOrder; const orderedProjectTabs = useMemo( () => orderProjectToolsTabs(visibleTabs, effectiveTabOrder), @@ -772,6 +799,18 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } }, [activeTab, gitReviewInitialized, onActiveTabChange]); + useEffect(() => { + const previousTunnelInitialized = previousTunnelInitializedRef.current; + previousTunnelInitializedRef.current = tunnelInitialized; + if (tunnelInitialized && !previousTunnelInitialized) { + onActiveTabChange("tunnel"); + return; + } + if (!tunnelInitialized && previousTunnelInitialized && activeTab === "tunnel") { + onActiveTabChange("terminal"); + } + }, [activeTab, onActiveTabChange, tunnelInitialized]); + const publishSessions = useCallback( (nextSessions: TerminalSession[], options?: { notifyParent?: boolean }) => { const sorted = sortSessions(nextSessions); @@ -1244,8 +1283,12 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { [clampedWidth, onWidthChange, panelWidth], ); + const showDisabledMessage = Boolean(disabledMessage && !tunnelAvailable && !tunnelInitialized); const showProjectToolsChooser = - projectReady && currentActiveTab === "terminal" && !activeSession; + !showDisabledMessage && + (projectReady || tunnelAvailable) && + currentActiveTab === "terminal" && + !activeSession; const startFileTree = useCallback(() => { setFileTreeInitialized(true); @@ -1302,6 +1345,19 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { } }, [activeTab, onActiveTabChange, onGitReviewOpenChange]); + const startTunnel = useCallback(() => { + if (!tunnelClient) return; + onTunnelOpenChange?.(true); + onActiveTabChange("tunnel"); + }, [onActiveTabChange, onTunnelOpenChange, tunnelClient]); + + const closeTunnelTab = useCallback(() => { + onTunnelOpenChange?.(false); + if (activeTab === "tunnel") { + onActiveTabChange("terminal"); + } + }, [activeTab, onActiveTabChange, onTunnelOpenChange]); + const renderCreateTerminalMenuItem = () => { if (shellOptions.length > 1) { return ( @@ -1507,6 +1563,61 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { ); } + if (tab.kind === "tunnel") { + return ( +
+ +
+ ); + } + const session = tab.session; const isPendingClose = pendingCloseSessionId === session.id; const isClosing = closingSessionId === session.id; @@ -1593,7 +1704,7 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) {
) : null} - {disabledMessage ? ( + {showDisabledMessage ? (
{disabledMessage}
@@ -1704,7 +1823,9 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) {
+
{loading ? (
@@ -1784,6 +1923,22 @@ export function ProjectToolsPanel(props: ProjectToolsPanelProps) { />
) : null} + {tunnelInitialized && tunnelClient ? ( +
+ +
+ ) : null} {sessions.length > 0 ? (
> = { "chat.runtime.webSearchOn": "联网搜索已开启", "chat.runtime.webSearchOff": "联网搜索已关闭", "chat.runtime.webSearchTooltip": "联网搜索", + "chat.runtime.tunnelToolAvailable": "内网穿透工具已可用", + "chat.runtime.tunnelToolUnavailable": "内网穿透工具不可用", + "chat.runtime.tunnelAgentModeRequired": "Agent 模式下可使用内网穿透工具", + "chat.runtime.tunnelWebDisabled": "Remote 设置未允许 WebUI 内网穿透", + "chat.runtime.tunnelRemoteOffline": "Remote Gateway 未连接", + "chat.runtime.tunnelSettingsSyncing": "正在同步桌面端设置", "chat.runtime.reasoning": "思考程度", "chat.emptyRound": "(无回复)", "chat.inputHint": "输入消息,@ 引用文件,Enter 发送,Shift+Enter 换行", @@ -232,6 +238,7 @@ export const translations: Record> = { "projectTools.terminalTitle": "终端", "projectTools.fileTreeTitle": "文件树", "projectTools.gitReviewTitle": "Git 审查", + "projectTools.tunnelTitle": "内网穿透", "projectTools.resizePanel": "调整项目工具栏宽度", "projectTools.getStarted": "开始使用", "projectTools.getStartedHint": "选择一个工具开始", @@ -241,6 +248,13 @@ export const translations: Record> = { "projectTools.fileTreeDescription": "浏览和管理项目文件", "projectTools.newGitReview": "新建审查", "projectTools.gitReviewDescription": "查看代码变更和提交历史", + "projectTools.newTunnel": "新建内网穿透", + "projectTools.tunnelDescription": "通过 Gateway 暴露本机 HTTP 服务", + "projectTools.tunnelScopeGroup": "切换内网穿透视角", + "projectTools.tunnelScopeProject": "当前项目", + "projectTools.tunnelScopeGlobal": "全局", + "projectTools.tunnelScopeProjectTitle": "管理当前项目内网穿透", + "projectTools.tunnelScopeGlobalTitle": "管理全局内网穿透", "projectTools.newProjectTool": "新建项目工具", "projectTools.closePanel": "关闭项目工具栏", "projectTools.close": "关闭", @@ -250,6 +264,46 @@ export const translations: Record> = { "projectTools.closeRunningTerminal": "关闭正在运行的终端「{title}」?", "projectTools.closeFileTree": "关闭文件树", "projectTools.closeGitReview": "关闭 Git 审查", + "projectTools.closeTunnelTab": "关闭内网穿透", + "projectTools.tunnelTargetUrl": "本地服务地址", + "projectTools.tunnelTargetPlaceholder": "http://localhost:3000", + "projectTools.tunnelName": "名称", + "projectTools.tunnelNamePlaceholder": "可选", + "projectTools.tunnelTtl": "有效期", + "projectTools.tunnelTtl15m": "15m", + "projectTools.tunnelTtl1h": "1h", + "projectTools.tunnelTtl4h": "4h", + "projectTools.tunnelTtlInfinite": "无限", + "projectTools.tunnelCreate": "创建临时链接", + "projectTools.tunnelCreating": "创建中...", + "projectTools.tunnelEdit": "编辑链接", + "projectTools.tunnelSave": "保存修改", + "projectTools.tunnelUpdating": "保存中...", + "projectTools.tunnelCancelEdit": "取消编辑", + "projectTools.tunnelLoading": "正在加载内网穿透...", + "projectTools.tunnelEmpty": "还没有内网穿透链接", + "projectTools.tunnelCreateSection": "新建链接", + "projectTools.tunnelListSection": "链接列表", + "projectTools.tunnelEmptyHintCreate": "在上方新建第一个临时链接", + "projectTools.tunnelEmptyHintProject": "切换到「当前项目」即可创建链接", + "projectTools.tunnelTargetRequired": "请输入本地 HTTP 服务地址。", + "projectTools.tunnelInvalidUrl": + "请输入有效的 http://localhost 地址,不能包含账号、密码或片段。", + "projectTools.tunnelLocalhostOnly": "仅支持 localhost、127.0.0.1 或 [::1]。", + "projectTools.tunnelRemoteOffline": "Remote Gateway 未连接,连接后才能创建或关闭 tunnel。", + "projectTools.tunnelWebDisabled": "桌面端 Remote 设置未允许 WebUI 创建或关闭内网穿透。", + "projectTools.tunnelStatusActive": "运行中", + "projectTools.tunnelStatusExpired": "已过期", + "projectTools.tunnelStatusOffline": "离线", + "projectTools.tunnelTarget": "目标", + "projectTools.tunnelExpiresIn": "剩余 {time}", + "projectTools.tunnelExpired": "已过期", + "projectTools.tunnelScopeProjectBadge": "项目", + "projectTools.tunnelScopeGlobalBadge": "全局", + "projectTools.tunnelCopyLink": "复制链接", + "projectTools.tunnelCopied": "已复制", + "projectTools.tunnelOpenLink": "打开链接", + "projectTools.tunnelClose": "删除链接", "projectTools.gitReview.viewChanges": "查看更改", "projectTools.gitReview.discardChanges": "放弃更改", "projectTools.gitReview.stageChanges": "暂存更改", @@ -951,6 +1005,9 @@ export const translations: Record> = { "settings.remoteWebGit": "允许 WebUI Git", "settings.remoteWebGitHint": "开启后,已登录 WebUI 可对本机项目执行分支、暂存、提交和同步操作。", + "settings.remoteWebTunnels": "允许 WebUI 内网穿透", + "settings.remoteWebTunnelsHint": + "开启后,已登录 WebUI 可为本机 localhost HTTP 服务创建和关闭临时访问链接。", "settings.remoteHeartbeat": "心跳间隔", "settings.remoteHeartbeatUnit": "秒", @@ -1296,6 +1353,12 @@ export const translations: Record> = { "chat.runtime.webSearchOn": "Web search enabled", "chat.runtime.webSearchOff": "Web search disabled", "chat.runtime.webSearchTooltip": "Toggle web search", + "chat.runtime.tunnelToolAvailable": "Tunnel tool available", + "chat.runtime.tunnelToolUnavailable": "Tunnel tool unavailable", + "chat.runtime.tunnelAgentModeRequired": "Tunnel tool requires Agent mode", + "chat.runtime.tunnelWebDisabled": "Remote WebUI tunnels are not allowed", + "chat.runtime.tunnelRemoteOffline": "Remote Gateway is offline", + "chat.runtime.tunnelSettingsSyncing": "Syncing desktop settings", "chat.runtime.reasoning": "Thinking effort", "chat.emptyRound": "(No reply)", "chat.inputHint": "Type a message, @ to mention files, Enter to send, Shift+Enter for newline", @@ -1442,6 +1505,7 @@ export const translations: Record> = { "projectTools.terminalTitle": "Terminal", "projectTools.fileTreeTitle": "File Tree", "projectTools.gitReviewTitle": "Git Review", + "projectTools.tunnelTitle": "Tunnel", "projectTools.resizePanel": "Resize project tools panel", "projectTools.getStarted": "Get Started", "projectTools.getStartedHint": "Choose a tool to begin", @@ -1451,6 +1515,13 @@ export const translations: Record> = { "projectTools.fileTreeDescription": "Browse and manage project files", "projectTools.newGitReview": "New Review", "projectTools.gitReviewDescription": "Review code changes and commit history", + "projectTools.newTunnel": "New Tunnel", + "projectTools.tunnelDescription": "Expose local HTTP services through Gateway", + "projectTools.tunnelScopeGroup": "Switch tunnel scope", + "projectTools.tunnelScopeProject": "Current Project", + "projectTools.tunnelScopeGlobal": "Global", + "projectTools.tunnelScopeProjectTitle": "Manage current project tunnels", + "projectTools.tunnelScopeGlobalTitle": "Manage global tunnels", "projectTools.newProjectTool": "New project tool", "projectTools.closePanel": "Close project tools panel", "projectTools.close": "Close", @@ -1460,6 +1531,48 @@ export const translations: Record> = { "projectTools.closeRunningTerminal": 'Close running terminal "{title}"?', "projectTools.closeFileTree": "Close File Tree", "projectTools.closeGitReview": "Close Git Review", + "projectTools.closeTunnelTab": "Close Tunnel", + "projectTools.tunnelTargetUrl": "Local service URL", + "projectTools.tunnelTargetPlaceholder": "http://localhost:3000", + "projectTools.tunnelName": "Name", + "projectTools.tunnelNamePlaceholder": "Optional", + "projectTools.tunnelTtl": "TTL", + "projectTools.tunnelTtl15m": "15m", + "projectTools.tunnelTtl1h": "1h", + "projectTools.tunnelTtl4h": "4h", + "projectTools.tunnelTtlInfinite": "Unlimited", + "projectTools.tunnelCreate": "Create temporary link", + "projectTools.tunnelCreating": "Creating...", + "projectTools.tunnelEdit": "Edit link", + "projectTools.tunnelSave": "Save changes", + "projectTools.tunnelUpdating": "Saving...", + "projectTools.tunnelCancelEdit": "Cancel edit", + "projectTools.tunnelLoading": "Loading tunnels...", + "projectTools.tunnelEmpty": "No tunnel links yet", + "projectTools.tunnelCreateSection": "New link", + "projectTools.tunnelListSection": "Links", + "projectTools.tunnelEmptyHintCreate": "Create your first temporary link above", + "projectTools.tunnelEmptyHintProject": "Switch to Current Project to create links", + "projectTools.tunnelTargetRequired": "Enter a local HTTP service URL.", + "projectTools.tunnelInvalidUrl": + "Enter a valid http://localhost URL without credentials or fragments.", + "projectTools.tunnelLocalhostOnly": "Only localhost, 127.0.0.1, or [::1] are supported.", + "projectTools.tunnelRemoteOffline": + "Remote Gateway is not connected. Connect it before creating or closing tunnels.", + "projectTools.tunnelWebDisabled": + "Desktop Remote settings do not allow WebUI tunnel create or close.", + "projectTools.tunnelStatusActive": "Active", + "projectTools.tunnelStatusExpired": "Expired", + "projectTools.tunnelStatusOffline": "Offline", + "projectTools.tunnelTarget": "Target", + "projectTools.tunnelExpiresIn": "{time} left", + "projectTools.tunnelExpired": "Expired", + "projectTools.tunnelScopeProjectBadge": "Project", + "projectTools.tunnelScopeGlobalBadge": "Global", + "projectTools.tunnelCopyLink": "Copy link", + "projectTools.tunnelCopied": "Copied", + "projectTools.tunnelOpenLink": "Open link", + "projectTools.tunnelClose": "Delete link", "projectTools.gitReview.viewChanges": "View Changes", "projectTools.gitReview.discardChanges": "Discard Changes", "projectTools.gitReview.stageChanges": "Stage Changes", @@ -2196,6 +2309,9 @@ export const translations: Record> = { "settings.remoteWebGit": "Allow WebUI Git", "settings.remoteWebGitHint": "Allow authenticated WebUI clients to run branch, stage, commit, and sync operations on local projects.", + "settings.remoteWebTunnels": "Allow WebUI Tunnels", + "settings.remoteWebTunnelsHint": + "Allow authenticated WebUI clients to create and close temporary links for local localhost HTTP services.", "settings.remoteHeartbeat": "Heartbeat Interval", "settings.remoteHeartbeatUnit": "seconds", diff --git a/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts b/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts index 2d442e406..d2b731129 100644 --- a/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts +++ b/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts @@ -7,13 +7,19 @@ type QueueEventOptions = { type GatewayBridgeEventControllerParams = { conversationId: string; requestId: string; + workerId?: string; enabled: boolean; - sendEvent: (requestId: string, event: Record) => void; + sendEvent: ( + requestId: string, + event: Record, + options?: { workerId?: string }, + ) => void; resolveErrorConversationId?: () => string; }; export type GatewayBridgeEventController = { queueEvent: (event: Record, options?: QueueEventOptions) => void; + queueStarted: () => void; queueToken: (delta: string, extra?: Record) => void; queueTitle: (nextTitle: string, allowAfterClose?: boolean) => void; queueToolStatus: (status: string | null, isCompaction?: boolean) => void; @@ -34,7 +40,7 @@ export function createGatewayBridgeEventController( const queueEvent = (event: Record, options?: QueueEventOptions) => { if (!params.enabled) return; if (streamClosed && !options?.allowAfterClose) return; - params.sendEvent(params.requestId, event); + params.sendEvent(params.requestId, event, { workerId: params.workerId }); }; const queueToolStatus = (status: string | null, isCompaction = false) => { @@ -52,6 +58,12 @@ export function createGatewayBridgeEventController( return { queueEvent, + queueStarted() { + queueEvent({ + type: "started", + conversation_id: params.conversationId, + }); + }, queueToken(delta: string, extra?: Record) { if (delta.length === 0 && !extra) return; if (delta.length > 0) { diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index 58c37e658..fd633cf85 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -127,11 +127,12 @@ export type ChatSidebarSettings = { recentCollapsed: boolean; }; -export type ProjectToolsPanelTab = "terminal" | "fileTree" | "gitReview"; +export type ProjectToolsPanelTab = "terminal" | "fileTree" | "gitReview" | "tunnel"; export type ProjectToolsPanelSettings = { width: number; activeTab: ProjectToolsPanelTab; + activeTabs: Record; tabOrders: Record; }; @@ -154,6 +155,11 @@ export type ProjectToolsGitReviewSettings = { openVersion: number; }; +export type ProjectToolsTunnelSettings = { + openProjectPathKeys: string[]; + openVersion: number; +}; + export type ProjectToolsFileTreeStatePatch = Partial & { bumpRevision?: boolean; bumpStateVersion?: boolean; @@ -165,6 +171,7 @@ export type CustomSettings = { projectToolsPanel: ProjectToolsPanelSettings; projectToolsFileTree: ProjectToolsFileTreeSettings; projectToolsGitReview: ProjectToolsGitReviewSettings; + projectToolsTunnel: ProjectToolsTunnelSettings; }; export type UpdateSettings = { @@ -256,6 +263,7 @@ export type RemoteSettings = { heartbeatInterval: number; enableWebTerminal: boolean; enableWebGit: boolean; + enableWebTunnels: boolean; }; export type AppSettings = { @@ -1047,6 +1055,7 @@ export function normalizeRemoteSettings(input: unknown): RemoteSettings { heartbeatInterval: normalizePositiveInteger(obj.heartbeatInterval, 30), enableWebTerminal: obj.enableWebTerminal === true, enableWebGit: obj.enableWebGit === true, + enableWebTunnels: obj.enableWebTunnels === true, }; } @@ -1537,6 +1546,23 @@ export function normalizeProjectToolsGitReviewSettings( }; } +export function normalizeProjectToolsTunnelSettings( + input: unknown, +): ProjectToolsTunnelSettings { + const obj = (input && typeof input === "object" ? input : {}) as Record; + const openProjectPathKeys = Array.from( + new Set( + (Array.isArray(obj.openProjectPathKeys) ? obj.openProjectPathKeys : []) + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean), + ), + ).sort(); + return { + openProjectPathKeys, + openVersion: normalizeIntegerInRange(obj.openVersion, 0, Number.MAX_SAFE_INTEGER, 0), + }; +} + export function normalizeProjectToolsPanelTabOrder(input: unknown): string[] { if (!Array.isArray(input)) return []; const order: string[] = []; @@ -1552,6 +1578,33 @@ export function normalizeProjectToolsPanelTabOrder(input: unknown): string[] { return order; } +function isProjectToolsPanelTab(input: unknown): input is ProjectToolsPanelTab { + return input === "terminal" || + input === "fileTree" || + input === "gitReview" || + input === "tunnel"; +} + +export function normalizeProjectToolsPanelActiveTab(input: unknown): ProjectToolsPanelTab { + return isProjectToolsPanelTab(input) ? input : "fileTree"; +} + +export function normalizeProjectToolsPanelActiveTabs( + input: unknown, +): Record { + const rawTabs = ( + input && typeof input === "object" && !Array.isArray(input) ? input : {} + ) as Record; + const activeTabs: Record = {}; + for (const [pathKey, value] of Object.entries(rawTabs)) { + const normalizedPathKey = workspaceProjectPathKey(pathKey); + if (!normalizedPathKey || !isProjectToolsPanelTab(value)) continue; + activeTabs[normalizedPathKey] = value; + if (Object.keys(activeTabs).length >= 100) break; + } + return activeTabs; +} + export function normalizeProjectToolsPanelTabOrders(input: unknown): Record { const rawOrders = ( input && typeof input === "object" && !Array.isArray(input) ? input : {} @@ -1582,12 +1635,9 @@ export function normalizeCustomSettings( const projectToolsPanel = ( obj.projectToolsPanel && typeof obj.projectToolsPanel === "object" ? obj.projectToolsPanel : {} ) as Record; - const projectToolsPanelActiveTab = - projectToolsPanel.activeTab === "terminal" || - projectToolsPanel.activeTab === "fileTree" || - projectToolsPanel.activeTab === "gitReview" - ? projectToolsPanel.activeTab - : "fileTree"; + const projectToolsPanelActiveTab = normalizeProjectToolsPanelActiveTab( + projectToolsPanel.activeTab, + ); const projectToolsFileTree = ( obj.projectToolsFileTree && typeof obj.projectToolsFileTree === "object" ? obj.projectToolsFileTree @@ -1598,6 +1648,11 @@ export function normalizeCustomSettings( ? obj.projectToolsGitReview : {} ) as unknown; + const projectToolsTunnel = ( + obj.projectToolsTunnel && typeof obj.projectToolsTunnel === "object" + ? obj.projectToolsTunnel + : {} + ) as unknown; return { conversationTitleModel: normalizeSelectedModelForProviders( normalizeSelectedModel(obj.conversationTitleModel), @@ -1615,10 +1670,12 @@ export function normalizeCustomSettings( 420, ), activeTab: projectToolsPanelActiveTab, + activeTabs: normalizeProjectToolsPanelActiveTabs(projectToolsPanel.activeTabs), tabOrders: normalizeProjectToolsPanelTabOrders(projectToolsPanel.tabOrders), }, projectToolsFileTree: normalizeProjectToolsFileTreeSettings(projectToolsFileTree), projectToolsGitReview: normalizeProjectToolsGitReviewSettings(projectToolsGitReview), + projectToolsTunnel: normalizeProjectToolsTunnelSettings(projectToolsTunnel), }; } @@ -1660,6 +1717,7 @@ export function getDefaultSettings(): AppSettings { heartbeatInterval: 30, enableWebTerminal: false, enableWebGit: false, + enableWebTunnels: false, }, memory: normalizeMemorySettings({}, customProviders), customSettings: normalizeCustomSettings({}, customProviders), @@ -1799,6 +1857,10 @@ function hasProjectToolsGitReviewSessionState(state: ProjectToolsGitReviewSettin return state.openVersion > 0 || state.openProjectPathKeys.length > 0; } +function hasProjectToolsTunnelSessionState(state: ProjectToolsTunnelSettings): boolean { + return state.openVersion > 0 || state.openProjectPathKeys.length > 0; +} + export function preserveProjectToolsSessionState( next: AppSettings, current: AppSettings, @@ -1809,6 +1871,9 @@ export function preserveProjectToolsSessionState( const currentGitReview = normalizeProjectToolsGitReviewSettings( current.customSettings.projectToolsGitReview, ); + const currentTunnel = normalizeProjectToolsTunnelSettings( + current.customSettings.projectToolsTunnel, + ); return normalizeSettings({ ...next, @@ -1820,6 +1885,9 @@ export function preserveProjectToolsSessionState( projectToolsGitReview: hasProjectToolsGitReviewSessionState(currentGitReview) ? currentGitReview : next.customSettings.projectToolsGitReview, + projectToolsTunnel: hasProjectToolsTunnelSessionState(currentTunnel) + ? currentTunnel + : next.customSettings.projectToolsTunnel, }, }); } @@ -1833,6 +1901,56 @@ export function getProjectToolsPanelTabOrder( return customSettings.projectToolsPanel.tabOrders[normalizedPathKey] ?? []; } +export function getProjectToolsPanelActiveTab( + customSettings: CustomSettings, + projectPathKey: string, +): ProjectToolsPanelTab { + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + if (!normalizedPathKey) return customSettings.projectToolsPanel.activeTab; + return ( + customSettings.projectToolsPanel.activeTabs[normalizedPathKey] ?? + customSettings.projectToolsPanel.activeTab + ); +} + +export function updateProjectToolsPanelActiveTab( + prev: AppSettings, + projectPathKey: string, + activeTab: ProjectToolsPanelTab, +): AppSettings { + const nextActiveTab = normalizeProjectToolsPanelActiveTab(activeTab); + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + if (!normalizedPathKey) { + if (prev.customSettings.projectToolsPanel.activeTab === nextActiveTab) return prev; + return updateCustomSettings(prev, { + projectToolsPanel: { + ...prev.customSettings.projectToolsPanel, + activeTab: nextActiveTab, + }, + }); + } + + const currentProjectActiveTab = + prev.customSettings.projectToolsPanel.activeTabs[normalizedPathKey]; + if ( + prev.customSettings.projectToolsPanel.activeTab === nextActiveTab && + currentProjectActiveTab === nextActiveTab + ) { + return prev; + } + + return updateCustomSettings(prev, { + projectToolsPanel: { + ...prev.customSettings.projectToolsPanel, + activeTab: nextActiveTab, + activeTabs: { + ...prev.customSettings.projectToolsPanel.activeTabs, + [normalizedPathKey]: nextActiveTab, + }, + }, + }); +} + function projectToolsPanelTabOrderEqual(left: readonly string[], right: readonly string[]) { return left.length === right.length && left.every((item, index) => item === right[index]); } @@ -1874,6 +1992,10 @@ export function removeProjectToolsProjectState( prev.customSettings.projectToolsPanel.tabOrders, normalizedPathKey, ); + const hasActiveTab = Object.hasOwn( + prev.customSettings.projectToolsPanel.activeTabs, + normalizedPathKey, + ); const openProjectPathKeys = prev.customSettings.projectToolsFileTree.openProjectPathKeys .map((pathKey) => workspaceProjectPathKey(pathKey)) .filter(Boolean); @@ -1881,15 +2003,22 @@ export function removeProjectToolsProjectState( (pathKey) => pathKey !== normalizedPathKey, ); const removedOpenProjectPathKey = nextOpenProjectPathKeys.length !== openProjectPathKeys.length; - const gitReviewOpenProjectPathKeys = - prev.customSettings.projectToolsGitReview.openProjectPathKeys - .map((pathKey) => workspaceProjectPathKey(pathKey)) - .filter(Boolean); + const gitReviewOpenProjectPathKeys = prev.customSettings.projectToolsGitReview.openProjectPathKeys + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean); const nextGitReviewOpenProjectPathKeys = gitReviewOpenProjectPathKeys.filter( (pathKey) => pathKey !== normalizedPathKey, ); const removedGitReviewOpenProjectPathKey = nextGitReviewOpenProjectPathKeys.length !== gitReviewOpenProjectPathKeys.length; + const tunnelOpenProjectPathKeys = prev.customSettings.projectToolsTunnel.openProjectPathKeys + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean); + const nextTunnelOpenProjectPathKeys = tunnelOpenProjectPathKeys.filter( + (pathKey) => pathKey !== normalizedPathKey, + ); + const removedTunnelOpenProjectPathKey = + nextTunnelOpenProjectPathKeys.length !== tunnelOpenProjectPathKeys.length; const hasFileTreeProjectState = Object.hasOwn( prev.customSettings.projectToolsFileTree.projects, normalizedPathKey, @@ -1898,8 +2027,10 @@ export function removeProjectToolsProjectState( if ( !hasTabOrder && + !hasActiveTab && !removedOpenProjectPathKey && !removedGitReviewOpenProjectPathKey && + !removedTunnelOpenProjectPathKey && !hasFileTreeProjectState ) { return prev; @@ -1911,6 +2042,12 @@ export function removeProjectToolsProjectState( if (hasTabOrder) { delete tabOrders[normalizedPathKey]; } + const activeTabs = hasActiveTab + ? { ...prev.customSettings.projectToolsPanel.activeTabs } + : prev.customSettings.projectToolsPanel.activeTabs; + if (hasActiveTab) { + delete activeTabs[normalizedPathKey]; + } const projects = hasFileTreeProjectState ? { ...prev.customSettings.projectToolsFileTree.projects } @@ -1922,6 +2059,7 @@ export function removeProjectToolsProjectState( return updateCustomSettings(prev, { projectToolsPanel: { ...prev.customSettings.projectToolsPanel, + activeTabs, tabOrders, }, projectToolsFileTree: { @@ -1943,6 +2081,15 @@ export function removeProjectToolsProjectState( ? prev.customSettings.projectToolsGitReview.openVersion + 1 : prev.customSettings.projectToolsGitReview.openVersion, }, + projectToolsTunnel: { + ...prev.customSettings.projectToolsTunnel, + openProjectPathKeys: removedTunnelOpenProjectPathKey + ? nextTunnelOpenProjectPathKeys.sort() + : prev.customSettings.projectToolsTunnel.openProjectPathKeys, + openVersion: removedTunnelOpenProjectPathKey + ? prev.customSettings.projectToolsTunnel.openVersion + 1 + : prev.customSettings.projectToolsTunnel.openVersion, + }, }); } @@ -2034,6 +2181,44 @@ export function updateProjectToolsGitReviewOpen( }); } +export function isProjectToolsTunnelOpen( + customSettings: CustomSettings, + projectPathKey: string, +): boolean { + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + return ( + normalizedPathKey !== "" && + customSettings.projectToolsTunnel.openProjectPathKeys.includes(normalizedPathKey) + ); +} + +export function updateProjectToolsTunnelOpen( + prev: AppSettings, + projectPathKey: string, + open: boolean, +): AppSettings { + const normalizedPathKey = workspaceProjectPathKey(projectPathKey); + if (!normalizedPathKey) return prev; + const openProjectPathKeys = new Set( + prev.customSettings.projectToolsTunnel.openProjectPathKeys + .map((pathKey) => workspaceProjectPathKey(pathKey)) + .filter(Boolean), + ); + if (openProjectPathKeys.has(normalizedPathKey) === open) return prev; + if (open) { + openProjectPathKeys.add(normalizedPathKey); + } else { + openProjectPathKeys.delete(normalizedPathKey); + } + return updateCustomSettings(prev, { + projectToolsTunnel: { + ...prev.customSettings.projectToolsTunnel, + openProjectPathKeys: Array.from(openProjectPathKeys).sort(), + openVersion: prev.customSettings.projectToolsTunnel.openVersion + 1, + }, + }); +} + function projectToolsFileTreeProjectStateEqual( left: ProjectToolsFileTreeProjectState, right: ProjectToolsFileTreeProjectState, diff --git a/crates/agent-gui/src/lib/settings/storage.ts b/crates/agent-gui/src/lib/settings/storage.ts index e7361e923..e2f715d19 100644 --- a/crates/agent-gui/src/lib/settings/storage.ts +++ b/crates/agent-gui/src/lib/settings/storage.ts @@ -8,6 +8,9 @@ import { normalizeChatRuntimeControls, normalizeProjectToolsFileTreeSettings, normalizeProjectToolsGitReviewSettings, + normalizeProjectToolsTunnelSettings, + normalizeProjectToolsPanelActiveTab, + normalizeProjectToolsPanelActiveTabs, normalizeProjectToolsPanelTabOrders, normalizeSelectedModel, normalizeSettings, @@ -58,6 +61,7 @@ function toPersistedLocalCustomSettings( ...customSettings, projectToolsFileTree: normalizeProjectToolsFileTreeSettings({}), projectToolsGitReview: normalizeProjectToolsGitReviewSettings({}), + projectToolsTunnel: normalizeProjectToolsTunnelSettings({}), }; } @@ -92,12 +96,9 @@ function readLocalUiSettings(): { typeof legacyTerminalPanel.width === "string" ? Number(legacyTerminalPanel.width) : 420; - const projectToolsPanelActiveTab = - projectToolsPanel.activeTab === "terminal" || - projectToolsPanel.activeTab === "fileTree" || - projectToolsPanel.activeTab === "gitReview" - ? projectToolsPanel.activeTab - : "fileTree"; + const projectToolsPanelActiveTab = normalizeProjectToolsPanelActiveTab( + projectToolsPanel.activeTab, + ); return toPersistedLocalCustomSettings({ conversationTitleModel: normalizeSelectedModel(obj.conversationTitleModel), chatSidebar: { @@ -109,10 +110,12 @@ function readLocalUiSettings(): { ? Math.min(1280, Math.max(320, Math.floor(projectToolsPanelWidth))) : 420, activeTab: projectToolsPanelActiveTab, + activeTabs: normalizeProjectToolsPanelActiveTabs(projectToolsPanel.activeTabs), tabOrders: normalizeProjectToolsPanelTabOrders(projectToolsPanel.tabOrders), }, projectToolsFileTree: normalizeProjectToolsFileTreeSettings({}), projectToolsGitReview: normalizeProjectToolsGitReviewSettings({}), + projectToolsTunnel: normalizeProjectToolsTunnelSettings({}), }); } diff --git a/crates/agent-gui/src/lib/settings/sync.ts b/crates/agent-gui/src/lib/settings/sync.ts index 0d7282e8e..f3b1bfb3e 100644 --- a/crates/agent-gui/src/lib/settings/sync.ts +++ b/crates/agent-gui/src/lib/settings/sync.ts @@ -3,6 +3,7 @@ import { normalizeChatRuntimeControls, normalizeProjectToolsFileTreeSettings, normalizeProjectToolsGitReviewSettings, + normalizeProjectToolsTunnelSettings, normalizeSettings, workspaceProjectPathKey, } from "./index"; @@ -11,6 +12,10 @@ export type GatewayProviderApiKeyUpdates = Record; export type GatewaySettingsSyncProvider = Omit & { apiKeyConfigured?: boolean; }; +export type GatewaySettingsSyncCustomSettings = Omit< + Partial, + "projectToolsPanel" +>; export type GatewaySettingsSyncPayload = { system: AppSettings["system"]; @@ -19,9 +24,9 @@ export type GatewaySettingsSyncPayload = { agents: AppSettings["agents"]; hooks: AppSettings["hooks"]; cron: AppSettings["cron"]; - remote?: Pick; + remote?: Pick; memory: AppSettings["memory"]; - customSettings: Partial; + customSettings: GatewaySettingsSyncCustomSettings; skills: AppSettings["skills"]; chatRuntimeControls: AppSettings["chatRuntimeControls"]; selectedModel: AppSettings["selectedModel"] | null; @@ -82,9 +87,10 @@ function collectProviderApiKeyUpdates( return Object.keys(updates).length > 0 ? updates : undefined; } -function syncableCustomSettings(customSettings: AppSettings["customSettings"]) { - const syncable = { ...customSettings } as Partial; - delete syncable.projectToolsPanel; +function syncableCustomSettings( + customSettings: AppSettings["customSettings"], +): GatewaySettingsSyncCustomSettings { + const { projectToolsPanel: _projectToolsPanel, ...syncable } = customSettings; return { ...syncable, chatSidebar: { @@ -161,10 +167,7 @@ function mergeSyncedSystemSettings( } const incomingSystem = incoming as AppSettings["system"]; - const activeWorkspaceProjectId = resolveSyncedActiveWorkspaceProjectId( - current, - incomingSystem, - ); + const activeWorkspaceProjectId = resolveSyncedActiveWorkspaceProjectId(current, incomingSystem); if (!Array.isArray(incomingSystem.workspaceProjects)) { return { ...incomingSystem, @@ -247,7 +250,11 @@ function mergeSyncedRemoteSettings( incoming: unknown, ): AppSettings["remote"] { const source = asObject(incoming); - if (!Object.hasOwn(source, "enableWebTerminal") && !Object.hasOwn(source, "enableWebGit")) { + if ( + !Object.hasOwn(source, "enableWebTerminal") && + !Object.hasOwn(source, "enableWebGit") && + !Object.hasOwn(source, "enableWebTunnels") + ) { return current; } return { @@ -258,6 +265,9 @@ function mergeSyncedRemoteSettings( enableWebGit: Object.hasOwn(source, "enableWebGit") ? source.enableWebGit === true : current.enableWebGit, + enableWebTunnels: Object.hasOwn(source, "enableWebTunnels") + ? source.enableWebTunnels === true + : current.enableWebTunnels, }; } @@ -318,6 +328,21 @@ function mergeSyncedProjectToolsGitReviewSettings( }; } +function mergeSyncedProjectToolsTunnelSettings( + current: AppSettings["customSettings"]["projectToolsTunnel"], + incoming: unknown, +): AppSettings["customSettings"]["projectToolsTunnel"] { + const currentState = normalizeProjectToolsTunnelSettings(current); + const incomingState = normalizeProjectToolsTunnelSettings(incoming); + const openFromIncoming = incomingState.openVersion >= currentState.openVersion; + return { + openProjectPathKeys: openFromIncoming + ? incomingState.openProjectPathKeys + : currentState.openProjectPathKeys, + openVersion: Math.max(currentState.openVersion, incomingState.openVersion), + }; +} + export function buildGatewaySettingsSyncPayload( settings: AppSettings, options: { includeProviderApiKeyUpdates?: boolean } = {}, @@ -332,6 +357,7 @@ export function buildGatewaySettingsSyncPayload( remote: { enableWebTerminal: settings.remote.enableWebTerminal, enableWebGit: settings.remote.enableWebGit, + enableWebTunnels: settings.remote.enableWebTunnels, }, memory: settings.memory, customSettings: syncableCustomSettings(settings.customSettings), @@ -365,9 +391,9 @@ export function applyGatewaySettingsSyncPayload( ? ((source.memory as AppSettings["memory"] | null | undefined) ?? {}) : current.memory; const customSettings = Object.hasOwn(source, "customSettings") - ? ((source.customSettings as AppSettings["customSettings"] | null | undefined) ?? {}) + ? ((source.customSettings as GatewaySettingsSyncCustomSettings | null | undefined) ?? {}) : current.customSettings; - const incomingCustomSettings = customSettings as Partial; + const incomingCustomSettings = customSettings as GatewaySettingsSyncCustomSettings; return normalizeSettings({ ...current, @@ -398,6 +424,12 @@ export function applyGatewaySettingsSyncPayload( incomingCustomSettings.projectToolsGitReview, ) : current.customSettings.projectToolsGitReview, + projectToolsTunnel: Object.hasOwn(incomingCustomSettings, "projectToolsTunnel") + ? mergeSyncedProjectToolsTunnelSettings( + current.customSettings.projectToolsTunnel, + incomingCustomSettings.projectToolsTunnel, + ) + : current.customSettings.projectToolsTunnel, chatSidebar: current.customSettings.chatSidebar, projectToolsPanel: current.customSettings.projectToolsPanel, }, diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 17dfbcbbc..2ae91e9a6 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -34,6 +34,7 @@ import { createShellTools } from "./shellTools"; import type { SkillAccessPolicy } from "./skillAccessPolicy"; import { createSkillTools } from "./skillTools"; import { createTerminalTools } from "./terminalTools"; +import { createTunnelManagerTools } from "./tunnelManagerTools"; export type BuiltinToolRegistry = { tools: BuiltinToolBundle["tools"]; @@ -166,6 +167,23 @@ type BuildBuiltinBaseToolRegistryParams = { onMcpLoadError?: (message: string) => void; mcpLoadFailureMode?: "continue" | "throw"; memoryToolMode?: "rw" | "ro"; + remoteWebTunnelsEnabled?: boolean; + remoteGatewayOnline?: boolean; + tunnelProjectPathKey?: string; + onTunnelsChanged?: (change: { + action: "create" | "close"; + tunnel: { + id: string; + slug: string; + name: string; + targetUrl: string; + publicUrl: string; + createdAt: number; + expiresAt: number; + status: "active" | "expired" | "offline"; + projectPathKey?: string; + }; + }) => void | Promise; }; async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryParams) { @@ -217,6 +235,15 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP workdir: params.workdir, mode: params.memoryToolMode ?? "rw", }), + createTunnelManagerTools({ + enabled: + params.remoteWebTunnelsEnabled === true && + params.remoteGatewayOnline === true && + params.runtimeScope === "chat", + runtimeScope: params.runtimeScope, + projectPathKey: params.tunnelProjectPathKey, + onTunnelsChanged: params.onTunnelsChanged, + }), ...(params.runtimeScope === "chat" ? [ createTerminalTools({ diff --git a/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts b/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts new file mode 100644 index 000000000..310a0b98a --- /dev/null +++ b/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts @@ -0,0 +1,322 @@ +import type { Tool, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; +import { invoke } from "@tauri-apps/api/core"; +import { Type } from "typebox"; + +import { type BuiltinToolBundle, createBuiltinMetadataMap } from "./builtinTypes"; + +type TunnelTtlSeconds = 0 | 900 | 3600 | 14400; + +export const TUNNEL_MANAGER_CHANGED_EVENT = "liveagent:tunnel-manager-changed"; + +type TunnelSummary = { + id: string; + slug: string; + name: string; + targetUrl: string; + publicUrl: string; + createdAt: number; + expiresAt: number; + activeConnections: number; + status: "active" | "expired" | "offline"; + projectPathKey?: string; +}; + +type TunnelManagerTunnelSummary = Omit; + +export type TunnelChangeAction = "create" | "close"; + +export type TunnelManagerChange = { + action: TunnelChangeAction; + tunnel: TunnelManagerTunnelSummary; +}; + +type TunnelCreateInput = { + targetUrl: string; + name?: string; + ttlSeconds: TunnelTtlSeconds; + projectPathKey?: string; +}; + +type TunnelManagerAction = "list" | "create" | "close"; + +type TunnelManagerDetails = { + kind: "tunnel_manager"; + action: TunnelManagerAction; + tunnels?: TunnelManagerTunnelSummary[]; + tunnel?: TunnelManagerTunnelSummary; +}; + +const TUNNEL_MANAGER_TOOL: Tool = { + name: "TunnelManager", + description: + "Manage temporary Remote HTTP tunnels for localhost services through the Gateway. Use list to inspect active tunnels, create to expose a local http://localhost/127.0.0.1/[::1] service, and close to revoke a tunnel.", + parameters: Type.Object({ + action: Type.Union( + [Type.Literal("list"), Type.Literal("create"), Type.Literal("close")], + { + description: "Tunnel action to perform.", + }, + ), + targetUrl: Type.Optional( + Type.String({ + description: + "Required for action=create. Local HTTP target, e.g. http://localhost:3000 or http://127.0.0.1:5173/app.", + }), + ), + name: Type.Optional( + Type.String({ + description: "Optional display name for a created tunnel.", + }), + ), + ttlSeconds: Type.Optional( + Type.Union([Type.Literal(0), Type.Literal(900), Type.Literal(3600), Type.Literal(14400)], { + description: "Optional tunnel lifetime. Use 0 for unlimited. Defaults to 3600 seconds.", + }), + ), + id: Type.Optional( + Type.String({ + description: "Tunnel id for action=close. Preferred over slug when available.", + }), + ), + slug: Type.Optional( + Type.String({ + description: "Tunnel slug for action=close when id is not known.", + }), + ), + }), +}; + +function asErrorMessage(err: unknown) { + return err instanceof Error ? err.message : String(err); +} + +function asArgs(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function normalizeAction(value: unknown): TunnelManagerAction { + if (value === "list" || value === "create" || value === "close") { + return value; + } + throw new Error('TunnelManager.action must be "list", "create", or "close".'); +} + +function normalizeTtlSeconds(value: unknown): TunnelTtlSeconds { + if (value === undefined || value === null) { + return 3600; + } + if (value === 0 || value === 900 || value === 3600 || value === 14400) { + return value; + } + throw new Error("TunnelManager.ttlSeconds must be 0, 900, 3600, or 14400."); +} + +function normalizeOptionalText(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function formatRemaining(expiresAt: number) { + if (!expiresAt) return "unlimited"; + const seconds = Math.max(0, Math.floor(expiresAt - Date.now() / 1000)); + if (seconds <= 0) return "expired"; + const hours = Math.floor(seconds / 3600); + const minutes = Math.ceil((seconds % 3600) / 60); + if (hours <= 0) return `${minutes}m`; + return minutes > 0 && minutes < 60 ? `${hours}h ${minutes}m` : `${hours}h`; +} + +function stripConnectionCount(tunnel: TunnelSummary): TunnelManagerTunnelSummary { + const { activeConnections: _activeConnections, ...summary } = tunnel; + return summary; +} + +function formatTunnelLine(tunnel: TunnelManagerTunnelSummary) { + const name = tunnel.name.trim() || tunnel.targetUrl; + return [ + `- ${name}`, + ` id: ${tunnel.id}`, + ` slug: ${tunnel.slug}`, + ` target: ${tunnel.targetUrl}`, + ` public: ${tunnel.publicUrl}`, + ` status: ${tunnel.status}`, + ` ttl: ${formatRemaining(tunnel.expiresAt)}`, + ].join("\n"); +} + +function okResult(params: { + toolCall: ToolCall; + action: TunnelManagerAction; + text: string; + tunnels?: TunnelManagerTunnelSummary[]; + tunnel?: TunnelManagerTunnelSummary; +}): ToolResultMessage { + const details: TunnelManagerDetails = { + kind: "tunnel_manager", + action: params.action, + ...(params.tunnels ? { tunnels: params.tunnels } : {}), + ...(params.tunnel ? { tunnel: params.tunnel } : {}), + }; + return { + role: "toolResult", + toolCallId: params.toolCall.id, + toolName: params.toolCall.name, + content: [{ type: "text", text: params.text }], + details, + isError: false, + timestamp: Date.now(), + }; +} + +function errorResult( + toolCall: ToolCall, + message: string, + action: TunnelManagerAction = "list", +): ToolResultMessage { + return { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text: `TunnelManager failed: ${message}` }], + details: { + kind: "tunnel_manager", + action, + errors: [message], + }, + isError: true, + timestamp: Date.now(), + }; +} + +async function listTunnels() { + return invoke("gateway_tunnel_list"); +} + +async function createTunnel(input: TunnelCreateInput) { + return invoke("gateway_tunnel_create", { input }); +} + +async function closeTunnel(id: string) { + return invoke("gateway_tunnel_close", { tunnel_id: id }); +} + +async function executeTunnelManager( + toolCall: ToolCall, + params: { + projectPathKey?: string; + onTunnelsChanged?: (change: TunnelManagerChange) => void | Promise; + }, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) { + return { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text: "Cancelled" }], + details: {}, + isError: true, + timestamp: Date.now(), + }; + } + + try { + const args = asArgs(toolCall.arguments); + const action = normalizeAction(args.action); + + if (action === "list") { + const tunnels = (await listTunnels()).map(stripConnectionCount); + const text = + tunnels.length === 0 + ? "No Remote HTTP tunnels are currently registered." + : ["Remote HTTP tunnels:", ...tunnels.map(formatTunnelLine)].join("\n"); + return okResult({ toolCall, action, text, tunnels }); + } + + if (action === "create") { + const targetUrl = normalizeOptionalText(args.targetUrl); + if (!targetUrl) { + throw new Error("TunnelManager.targetUrl is required for action=create."); + } + const tunnel = await createTunnel({ + targetUrl, + name: normalizeOptionalText(args.name) || undefined, + ttlSeconds: normalizeTtlSeconds(args.ttlSeconds), + ...(params.projectPathKey?.trim() ? { projectPathKey: params.projectPathKey.trim() } : {}), + }); + const visibleTunnel = stripConnectionCount(tunnel); + await params.onTunnelsChanged?.({ action: "create", tunnel: visibleTunnel }); + return okResult({ + toolCall, + action, + text: ["Created Remote HTTP tunnel:", formatTunnelLine(visibleTunnel)].join("\n"), + tunnel: visibleTunnel, + }); + } + + const id = normalizeOptionalText(args.id); + const slug = normalizeOptionalText(args.slug); + if (!id && !slug) { + throw new Error("TunnelManager.id or TunnelManager.slug is required for action=close."); + } + + let tunnelId = id; + if (!tunnelId) { + const tunnels = await listTunnels(); + tunnelId = tunnels.find((tunnel) => tunnel.slug === slug)?.id ?? ""; + if (!tunnelId) { + throw new Error(`No tunnel found for slug "${slug}".`); + } + } + const tunnel = await closeTunnel(tunnelId); + const visibleTunnel = stripConnectionCount(tunnel); + await params.onTunnelsChanged?.({ action: "close", tunnel: visibleTunnel }); + return okResult({ + toolCall, + action, + text: ["Closed Remote HTTP tunnel:", formatTunnelLine(visibleTunnel)].join("\n"), + tunnel: visibleTunnel, + }); + } catch (err) { + const args = asArgs(toolCall.arguments); + const action = + args.action === "create" || args.action === "close" || args.action === "list" + ? args.action + : undefined; + return errorResult(toolCall, asErrorMessage(err), action); + } +} + +export function createTunnelManagerTools(params: { + enabled: boolean; + runtimeScope: "chat" | "cron_auto_prompt"; + projectPathKey?: string; + onTunnelsChanged?: (change: TunnelManagerChange) => void | Promise; +}): BuiltinToolBundle { + const tools = params.enabled && params.runtimeScope === "chat" ? [TUNNEL_MANAGER_TOOL] : []; + return { + groupId: "system", + tools, + executeToolCall: (toolCall, signal) => + executeTunnelManager( + toolCall, + { + projectPathKey: params.projectPathKey, + onTunnelsChanged: params.onTunnelsChanged, + }, + signal, + ), + metadataByName: createBuiltinMetadataMap( + tools.map((tool) => [ + tool.name, + { + groupId: "system" as const, + kind: "tunnel_manager", + isReadOnly: false, + displayCategory: "system" as const, + }, + ]), + ), + }; +} diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 10e0033c0..48ac3a99d 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -27,6 +27,12 @@ import { type NotifyItem, NotifyToast } from "../components/chat/NotifyToast"; import { SharedHistoryManagerModal } from "../components/chat/SharedHistoryManagerModal"; import { Ban, PanelRightClose, PanelRightOpen, Terminal, Upload } from "../components/icons"; import { ProjectToolsPanel } from "../components/project-tools/ProjectToolsPanel"; +import type { + LocalTunnelClient, + TunnelCreateInput, + TunnelSummary, + TunnelUpdateInput, +} from "../components/project-tools/LocalTunnelPanel"; import type { WorkspaceCodeEditorOpenRequest } from "../components/workspace-editor/WorkspaceCodeEditorOverlay"; import type { WorkspaceImagePreviewOpenRequest } from "../components/workspace-editor/WorkspaceImagePreviewOverlay"; import { isWorkspaceImagePath } from "../components/workspace-editor/workspaceImagePreview"; @@ -107,13 +113,14 @@ import { findProviderModelConfig, getChatRuntimeReasoningLevelsForProvider, getProjectToolsFileTreeProjectState, + getProjectToolsPanelActiveTab, getProjectToolsPanelTabOrder, isAgentDevMode, isAgentExecutionMode, isProjectToolsFileTreeOpen, isProjectToolsGitReviewOpen, + isProjectToolsTunnelOpen, normalizeChatRuntimeControlsForProvider, - type ProviderId, type SelectedModel, type SystemToolId, type WorkspaceProject, @@ -125,6 +132,8 @@ import { updateProjectToolsFileTreeProjectState, updateProjectToolsFileTreeOpen, updateProjectToolsGitReviewOpen, + updateProjectToolsTunnelOpen, + updateProjectToolsPanelActiveTab, updateProjectToolsPanelTabOrder, updateChatRuntimeControlsForProvider, updateMcp, @@ -173,10 +182,12 @@ import { startConversationTitleJob } from "./chat/conversationTitleJob"; import { type ActiveGatewayBridgeRequest, type EnsureGatewayBridgeConversationReadyOptions, - type GatewaySelectedModelEvent, - normalizeGatewayProviderType, type SendChatAction, } from "./chat/gatewayBridgeTypes"; +import { + type EffectiveChatModelSelection, + resolveEffectiveChatModelSelection, +} from "./chat/modelSelection"; import { runAgentConversationTurn } from "./chat/runAgentConversationTurn"; import { runTextConversationTurn } from "./chat/runTextConversationTurn"; import { clearSilentMemoryExtractionState } from "./chat/silentMemoryExtraction"; @@ -246,16 +257,6 @@ function buildFallbackGatewayStatus(remote: AppSettings["remote"]): GatewayRunti }; } -type EffectiveChatModelSelection = { - selectedModel: { - customProviderId: string; - model: string; - }; - provider: AppSettings["customProviders"][number]; - providerId: ProviderId; - model: string; -}; - type SyncedRunningConversationRuntime = { workdir?: string; updatedAt: number; @@ -426,58 +427,6 @@ async function importPastedTextsAsFiles(workdir: string, pastes: MentionComposer }; } -function resolveEffectiveChatModelSelection( - settings: AppSettings, - gatewaySelectedModel?: GatewaySelectedModelEvent, -): EffectiveChatModelSelection { - const resolveLocalSelection = (): EffectiveChatModelSelection => { - if (!settings.selectedModel) { - throw new Error("请先在左上角选择一个模型(或先去设置添加模型)。"); - } - - const { customProviderId, model } = settings.selectedModel; - const provider = settings.customProviders.find((item) => item.id === customProviderId); - if (!provider) { - throw new Error("所选供应商不存在,请重新选择模型。"); - } - - return { - selectedModel: settings.selectedModel, - provider, - providerId: provider.type, - model, - }; - }; - - if (!gatewaySelectedModel) { - return resolveLocalSelection(); - } - - const customProviderId = gatewaySelectedModel.customProviderId.trim(); - const model = gatewaySelectedModel.model.trim(); - const providerType = normalizeGatewayProviderType(gatewaySelectedModel.providerType); - if (!customProviderId || !model || !providerType) { - throw new Error("远程请求携带的模型配置无效,请在 WebUI 重新选择模型后重试。"); - } - - const exactProvider = settings.customProviders.find((item) => item.id === customProviderId); - const provider = - exactProvider ?? settings.customProviders.find((item) => item.type === providerType); - if (!provider) { - throw new Error("远程请求所选模型对应的供应商不存在,请先在桌面端配置该类型供应商。"); - } - - return { - selectedModel: { - customProviderId: provider.id, - model, - }, - provider, - providerId: provider.type, - model, - }; -} - function resolveMemorySummaryModelSelection( settings: AppSettings, ): EffectiveChatModelSelection | null { @@ -700,6 +649,7 @@ export function ChatPage(props: ChatPageProps) { const [sidebarOpen, setSidebarOpen] = useState(true); const [activeView, setActiveView] = useState<"chat" | "skills-hub" | "mcp-hub">("chat"); const [projectToolsPanelOpen, setProjectToolsPanelOpen] = useState(false); + const [tunnelRefreshToken, setTunnelRefreshToken] = useState(0); const previousProjectToolsFileTreeOpenRef = useRef(false); const [workspaceEditorMounted, setWorkspaceEditorMounted] = useState(false); const [workspaceEditorOpen, setWorkspaceEditorOpen] = useState(false); @@ -717,6 +667,17 @@ export function ChatPage(props: ChatPageProps) { const [remoteRuntimeStatus, setRemoteRuntimeStatus] = useState(() => buildFallbackGatewayStatus(settings.remote), ); + const tauriTunnelClient = useMemo( + () => ({ + listTunnels: () => invoke("gateway_tunnel_list"), + createTunnel: (input: TunnelCreateInput) => + invoke("gateway_tunnel_create", { input }), + updateTunnel: (input: TunnelUpdateInput) => + invoke("gateway_tunnel_update", { input }), + closeTunnel: (id: string) => invoke("gateway_tunnel_close", { tunnel_id: id }), + }), + [], + ); const { historyItems, @@ -1074,12 +1035,7 @@ export function ChatPage(props: ChatPageProps) { activateWorkspaceProject(project); setSettings((prev) => updateProjectToolsFileTreeOpen( - updateCustomSettings(prev, { - projectToolsPanel: { - ...prev.customSettings.projectToolsPanel, - activeTab: "fileTree", - }, - }), + updateProjectToolsPanelActiveTab(prev, pathKey, "fileTree"), pathKey, true, ), @@ -1088,6 +1044,21 @@ export function ChatPage(props: ChatPageProps) { [activateWorkspaceProject, checkWorkspaceProjectDirectory, setSettings], ); + const openTunnelToolPanel = useCallback((projectPathKey?: string) => { + const targetProjectPathKey = + workspaceProjectPathKey(projectPathKey) || workspaceProjectPathKey(activeWorkspaceProjectPath); + if (!targetProjectPathKey) return; + setActiveView("chat"); + setProjectToolsPanelOpen(true); + setSettings((prev) => + updateProjectToolsTunnelOpen( + updateProjectToolsPanelActiveTab(prev, targetProjectPathKey, "tunnel"), + targetProjectPathKey, + true, + ), + ); + }, [activeWorkspaceProjectPath, setSettings]); + const handleBrowseWorkspaceProjectInSystemFileManager = useCallback( async (project: WorkspaceProject) => { if (!(await checkWorkspaceProjectDirectory(project))) { @@ -1478,11 +1449,21 @@ export function ChatPage(props: ChatPageProps) { settings.customSettings, terminalProjectPathKey, ); + const projectToolsTunnelOpen = isProjectToolsTunnelOpen( + settings.customSettings, + terminalProjectPathKey, + ); const terminalDisabledMessage = !isAgentMode ? "Project tools require Agent project mode." : !terminalProjectPath ? "Select a project to use project tools." : undefined; + const tunnelEnabled = settings.remote.enableWebTunnels === true && remoteRuntimeStatus.online; + const tunnelDisabledMessage = !settings.remote.enableWebTunnels + ? t("projectTools.tunnelWebDisabled") + : !remoteRuntimeStatus.online + ? t("projectTools.tunnelRemoteOffline") + : undefined; const handleOpenWorkspaceFile = useCallback( (path: string) => { if (!terminalProjectPath || !terminalProjectPathKey) return; @@ -2625,6 +2606,7 @@ export function ChatPage(props: ChatPageProps) { const gatewayBridgeEvents = createGatewayBridgeEventController({ conversationId, requestId: gatewayBridgeRequest?.requestId ?? `conversation-live-${conversationId}`, + workerId: gatewayBridgeRequest?.workerId, enabled: Boolean(gatewayBridgeRequest) || hasRemoteGatewayTarget, sendEvent: queueGatewayBridgeEventForRequest, resolveErrorConversationId: () => @@ -2649,7 +2631,14 @@ export function ChatPage(props: ChatPageProps) { gatewayBridgeEvents.emitError(message, conversationId); throw new Error(message); } - if (runtimeEntry.isSending) return; + if (runtimeEntry.isSending) { + if (gatewayBridgeRequest) { + const message = "Conversation is already sending."; + gatewayBridgeEvents.emitError(message, conversationId); + gatewayBridgeEvents.close(); + } + return; + } if (isImportingPastedTextRef.current && typeof overrides?.textOverride !== "string") { return; } @@ -2767,7 +2756,14 @@ export function ChatPage(props: ChatPageProps) { } const userMessage = createUserMessageWithUploads(text, uploadedFiles, Date.now()); - if (!userMessage) return; + if (!userMessage) { + if (gatewayBridgeRequest) { + const message = "Message is required."; + gatewayBridgeEvents.emitError(message, conversationId); + gatewayBridgeEvents.close(); + } + return; + } const pendingUserMessage = userMessage; const content = typeof pendingUserMessage.content === "string" ? pendingUserMessage.content : ""; @@ -2877,6 +2873,7 @@ export function ChatPage(props: ChatPageProps) { pendingUserMessage, ]); let conversationRunStarted = false; + let gatewayRunStarted = false; let gatewayActivityPublishChain: Promise = Promise.resolve(); function queueGatewayConversationActivity(running: boolean) { gatewayActivityPublishChain = gatewayActivityPublishChain.then(() => @@ -2884,6 +2881,15 @@ export function ChatPage(props: ChatPageProps) { ); void gatewayActivityPublishChain; } + function acknowledgeGatewayRunStarted() { + if (gatewayRunStarted) { + return; + } + gatewayRunStarted = true; + gatewayBridgeEvents.queueStarted(); + gatewayBridgeEvents.queueToken("", { round: 0 }); + queueGatewayConversationActivity(true); + } function markConversationRunStarted() { if (conversationRunStarted) { return; @@ -2893,8 +2899,6 @@ export function ChatPage(props: ChatPageProps) { resetLiveTranscript(transcriptStore); setConversationAbortController(conversationId, requestController); setConversationSendingState(conversationId, true); - gatewayBridgeEvents.queueToken("", { round: 0 }); - queueGatewayConversationActivity(true); if (isConversationVisible()) { stickToBottom(); } @@ -2905,7 +2909,9 @@ export function ChatPage(props: ChatPageProps) { } setConversationAbortController(conversationId, null); setConversationSendingState(conversationId, false); - queueGatewayConversationActivity(false); + if (gatewayRunStarted) { + queueGatewayConversationActivity(false); + } } const shouldSynchronizeInitialPersistBeforeGatewayStream = @@ -2945,11 +2951,22 @@ export function ChatPage(props: ChatPageProps) { markConversationRunStopped(); return; } + acknowledgeGatewayRunStarted(); } else { if (shouldSynchronizeInitialPersistBeforeGatewayStream) { - await initialPersist; + const persisted = await initialPersist; + if (!persisted) { + const message = "历史记录保存失败,已取消本次远程对话。"; + setConversationErrorState(message); + gatewayBridgeEvents.emitError(message, conversationId); + gatewayBridgeEvents.close(); + markConversationRunStopped(); + return; + } + acknowledgeGatewayRunStarted(); } else { void initialPersist; + acknowledgeGatewayRunStarted(); } } let activeCompactionRollback: { @@ -3546,6 +3563,14 @@ export function ChatPage(props: ChatPageProps) { }, enabledMcpServerIds, selectableMcpServers, + remoteWebTunnelsEnabled: settings.remote.enableWebTunnels, + remoteGatewayOnline: canShareHistory, + onTunnelsChanged: (change) => { + setTunnelRefreshToken((current) => current + 1); + if (change.action === "create") { + openTunnelToolPanel(change.tunnel.projectPathKey); + } + }, sessionId, conversationId, conversationCwd, @@ -4582,7 +4607,10 @@ export function ChatPage(props: ChatPageProps) { width={settings.customSettings.projectToolsPanel.width} theme={settings.theme} disabledMessage={terminalDisabledMessage} - activeTab={settings.customSettings.projectToolsPanel.activeTab} + activeTab={getProjectToolsPanelActiveTab( + settings.customSettings, + terminalProjectPathKey, + )} tabOrder={getProjectToolsPanelTabOrder(settings.customSettings, terminalProjectPathKey)} fileTreeOpen={projectToolsFileTreeOpen} fileTreeState={getProjectToolsFileTreeProjectState( @@ -4590,9 +4618,14 @@ export function ChatPage(props: ChatPageProps) { terminalProjectPathKey, )} gitReviewOpen={isProjectToolsGitReviewOpen(settings.customSettings, terminalProjectPathKey)} + tunnelOpen={projectToolsTunnelOpen} client={tauriTerminalClient} gitClient={tauriGitClient} gitWriteEnabled + tunnelClient={isAgentMode ? tauriTunnelClient : null} + tunnelEnabled={tunnelEnabled} + tunnelDisabledMessage={tunnelDisabledMessage} + tunnelRefreshToken={tunnelRefreshToken} onWidthChange={(nextWidth) => setSettings((prev) => updateCustomSettings(prev, { @@ -4605,12 +4638,7 @@ export function ChatPage(props: ChatPageProps) { } onActiveTabChange={(activeTab) => setSettings((prev) => - updateCustomSettings(prev, { - projectToolsPanel: { - ...prev.customSettings.projectToolsPanel, - activeTab, - }, - }), + updateProjectToolsPanelActiveTab(prev, terminalProjectPathKey, activeTab), ) } onTabOrderChange={(tabOrder) => @@ -4629,6 +4657,9 @@ export function ChatPage(props: ChatPageProps) { onGitReviewOpenChange={(open) => setSettings((prev) => updateProjectToolsGitReviewOpen(prev, terminalProjectPathKey, open)) } + onTunnelOpenChange={(open) => + setSettings((prev) => updateProjectToolsTunnelOpen(prev, terminalProjectPathKey, open)) + } onSessionsChange={setProjectTerminalSessions} onInsertFileMention={(path, kind) => { composerRef.current?.insertFileMention(path, kind); diff --git a/crates/agent-gui/src/pages/chat/gatewayBridgeTypes.ts b/crates/agent-gui/src/pages/chat/gatewayBridgeTypes.ts index 1a8cc4432..b79d2caa5 100644 --- a/crates/agent-gui/src/pages/chat/gatewayBridgeTypes.ts +++ b/crates/agent-gui/src/pages/chat/gatewayBridgeTypes.ts @@ -36,6 +36,21 @@ export type GatewayChatRequestEvent = { uploadedFiles?: PendingUploadedFile[]; }; +export type GatewayChatClaimedRequest = { + requestId: string; + clientRequestId: string; + conversationId: string; + state: string; + attempt: number; + leaseMs: number; + request: GatewayChatRequestEvent; +}; + +export type GatewayChatRequestReadyEvent = { + requestId?: string; + reason?: string; +}; + export type EnsureGatewayBridgeConversationReadyOptions = { forceHydrate?: boolean; historyTruncationKey?: string; @@ -56,6 +71,7 @@ export type ActiveGatewayBridgeRequest = { requestId: string; conversationId: string; clientRequestId?: string; + workerId?: string; startedAt: number; selectedModelOverride?: GatewaySelectedModelEvent; runtimeControlsOverride?: ChatRuntimeControls; diff --git a/crates/agent-gui/src/pages/chat/modelSelection.ts b/crates/agent-gui/src/pages/chat/modelSelection.ts new file mode 100644 index 000000000..654fc867e --- /dev/null +++ b/crates/agent-gui/src/pages/chat/modelSelection.ts @@ -0,0 +1,71 @@ +import type { AppSettings, ProviderId, SelectedModel } from "../../lib/settings"; +import { + type GatewaySelectedModelEvent, + normalizeGatewayProviderType, +} from "./gatewayBridgeTypes"; + +export type EffectiveChatModelSelection = { + selectedModel: SelectedModel; + provider: AppSettings["customProviders"][number]; + providerId: ProviderId; + model: string; +}; + +export function resolveEffectiveChatModelSelection( + settings: AppSettings, + gatewaySelectedModel?: GatewaySelectedModelEvent, +): EffectiveChatModelSelection { + const resolveLocalSelection = (): EffectiveChatModelSelection => { + if (!settings.selectedModel) { + throw new Error("请先在左上角选择一个模型(或先去设置添加模型)。"); + } + + const { customProviderId, model } = settings.selectedModel; + const provider = settings.customProviders.find((item) => item.id === customProviderId); + if (!provider) { + throw new Error("所选供应商不存在,请重新选择模型。"); + } + if (!provider.activeModels.includes(model)) { + throw new Error("所选模型未启用,请重新选择模型。"); + } + + return { + selectedModel: settings.selectedModel, + provider, + providerId: provider.type, + model, + }; + }; + + if (!gatewaySelectedModel) { + return resolveLocalSelection(); + } + + const customProviderId = gatewaySelectedModel.customProviderId.trim(); + const model = gatewaySelectedModel.model.trim(); + const providerType = normalizeGatewayProviderType(gatewaySelectedModel.providerType); + if (!customProviderId || !model || !providerType) { + throw new Error("远程请求携带的模型配置无效,请在 WebUI 重新选择模型后重试。"); + } + + const provider = settings.customProviders.find((item) => item.id === customProviderId); + if (!provider) { + throw new Error("远程请求所选模型对应的供应商不存在,请同步桌面端设置后在 WebUI 重新选择模型。"); + } + if (provider.type !== providerType) { + throw new Error("远程请求所选模型的供应商类型与桌面端配置不一致,请同步桌面端设置后在 WebUI 重新选择模型。"); + } + if (!provider.activeModels.includes(model)) { + throw new Error("远程请求所选模型未在桌面端启用,请同步桌面端设置后在 WebUI 重新选择模型。"); + } + + return { + selectedModel: { + customProviderId: provider.id, + model, + }, + provider, + providerId: provider.type, + model, + }; +} diff --git a/crates/agent-gui/src/pages/chat/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/runAgentConversationTurn.ts index 6455e8c43..20709a4b3 100644 --- a/crates/agent-gui/src/pages/chat/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/runAgentConversationTurn.ts @@ -50,7 +50,16 @@ import type { SubagentRuntimeManager } from "../../lib/chat/subagent/subagentRun import { createSubagentScheduler } from "../../lib/chat/subagent/subagentScheduler"; import type { StreamDebugLogger } from "../../lib/debug/agentDebug"; import { assistantMessageToText } from "../../lib/providers/llm"; -import type { AppSettings, ProviderId, SystemToolId } from "../../lib/settings"; +import { + type AppSettings, + type ProviderId, + type SystemToolId, + workspaceProjectPathKey, +} from "../../lib/settings"; +import { + TUNNEL_MANAGER_CHANGED_EVENT, + type TunnelManagerChange, +} from "../../lib/tools/tunnelManagerTools"; import { buildBuiltinToolRegistry } from "../../lib/tools/builtinRegistry"; import type { BuiltinToolExecutionContext } from "../../lib/tools/builtinTypes"; import { createFileToolState } from "../../lib/tools/fileToolState"; @@ -276,6 +285,9 @@ type RunAgentConversationTurnParams = { updateMcpSettings?: (next: AppSettings["mcp"]) => void; enabledMcpServerIds: string[]; selectableMcpServers: AppSettings["mcp"]["servers"]; + remoteWebTunnelsEnabled?: boolean; + remoteGatewayOnline?: boolean; + onTunnelsChanged?: (change: TunnelManagerChange) => void; sessionId: string; conversationId: string; conversationCwd?: string; @@ -357,6 +369,9 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP updateMcpSettings, enabledMcpServerIds, selectableMcpServers, + remoteWebTunnelsEnabled, + remoteGatewayOnline, + onTunnelsChanged, sessionId, conversationId, conversationCwd, @@ -474,6 +489,15 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP updateMcpSettings, enabledMcpServerIds, selectableMcpServers, + remoteWebTunnelsEnabled, + remoteGatewayOnline, + tunnelProjectPathKey: workspaceProjectPathKey(effectiveWorkdir), + onTunnelsChanged: (change) => { + onTunnelsChanged?.(change); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(TUNNEL_MANAGER_CHANGED_EVENT)); + } + }, onMcpLoadError: (message) => { const warning = `MCP 工具加载失败,已跳过并继续对话:${message || "未知错误"}`; console.warn(warning); diff --git a/crates/agent-gui/src/pages/chat/useGatewayBridgeBatcher.ts b/crates/agent-gui/src/pages/chat/useGatewayBridgeBatcher.ts index 03fb23797..f379e774d 100644 --- a/crates/agent-gui/src/pages/chat/useGatewayBridgeBatcher.ts +++ b/crates/agent-gui/src/pages/chat/useGatewayBridgeBatcher.ts @@ -10,11 +10,16 @@ type BatchableGatewayBridgeEvent = { type PendingGatewayBridgeEventBatch = BatchableGatewayBridgeEvent & { requestId: string; + workerId?: string; rafId: number | null; timeoutId: number | null; microtaskQueued: boolean; }; +type GatewayBridgeSendOptions = { + workerId?: string; +}; + const GATEWAY_BRIDGE_BATCH_MAX_DELAY_MS = 32; const GATEWAY_BRIDGE_BATCH_MAX_TEXT_LENGTH = 640; @@ -61,13 +66,15 @@ export function useGatewayBridgeBatcher() { ); const sendGatewayBridgeEventForRequest = useCallback( - (requestId: string, event: Record) => { + (requestId: string, event: Record, options?: GatewayBridgeSendOptions) => { + const workerId = options?.workerId?.trim() || undefined; gatewayEventChainRef.current = gatewayEventChainRef.current .catch(() => undefined) .then(() => invoke("gateway_send_chat_event", { request_id: requestId, event, + worker_id: workerId, } as any), ) .then(() => undefined) @@ -102,6 +109,8 @@ export function useGatewayBridgeBatcher() { text: pending.text, conversation_id: pending.conversationId, ...(pending.round !== null ? { round: pending.round } : {}), + }, { + workerId: pending.workerId, }); }, [sendGatewayBridgeEventForRequest], @@ -157,20 +166,22 @@ export function useGatewayBridgeBatcher() { ); const queueGatewayBridgeEventForRequest = useCallback( - (requestId: string, event: Record) => { + (requestId: string, event: Record, options?: GatewayBridgeSendOptions) => { const batchable = toBatchableGatewayBridgeEvent(event); if (!batchable) { flushGatewayBridgeEventBatchForRequest(requestId); - sendGatewayBridgeEventForRequest(requestId, event); + sendGatewayBridgeEventForRequest(requestId, event, options); return; } + const workerId = options?.workerId?.trim() || undefined; const existing = pendingGatewayBridgeEventBatchesRef.current.get(requestId); if ( existing && existing.type === batchable.type && existing.conversationId === batchable.conversationId && - existing.round === batchable.round + existing.round === batchable.round && + existing.workerId === workerId ) { existing.text += batchable.text; if (existing.text.length >= GATEWAY_BRIDGE_BATCH_MAX_TEXT_LENGTH) { @@ -184,6 +195,7 @@ export function useGatewayBridgeBatcher() { flushGatewayBridgeEventBatchForRequest(requestId); pendingGatewayBridgeEventBatchesRef.current.set(requestId, { requestId, + workerId, ...batchable, rafId: null, timeoutId: null, diff --git a/crates/agent-gui/src/pages/chat/useGatewayBridgeListeners.ts b/crates/agent-gui/src/pages/chat/useGatewayBridgeListeners.ts index 4f1235459..2397024b5 100644 --- a/crates/agent-gui/src/pages/chat/useGatewayBridgeListeners.ts +++ b/crates/agent-gui/src/pages/chat/useGatewayBridgeListeners.ts @@ -1,3 +1,4 @@ +import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { useEffect } from "react"; @@ -14,15 +15,20 @@ import { import { type ActiveGatewayBridgeRequest, type GatewayBridgeRuntimeRefs, + type GatewayChatClaimedRequest, type GatewayChatCancelEvent, - type GatewayChatRequestEvent, + type GatewayChatRequestReadyEvent, type GatewayHistoryTruncatedEvent, normalizeGatewayExecutionMode, normalizeGatewayWorkdir, } from "./gatewayBridgeTypes"; type UseGatewayBridgeListenersParams = GatewayBridgeRuntimeRefs & { - queueGatewayBridgeEventForRequest: (requestId: string, event: Record) => void; + queueGatewayBridgeEventForRequest: ( + requestId: string, + event: Record, + options?: { workerId?: string }, + ) => void; isConversationRunning: (conversationId: string) => boolean; getConversationAbortController: (conversationId: string) => AbortController | null; syncVisibleConversationRuntime: (conversationId: string, entry: ConversationRuntimeEntry) => void; @@ -33,8 +39,22 @@ type GatewayBridgeRequestRegistry = { activeRequests: Map; pendingRequestIds: Set; pendingClientRequestIds: Set; + pendingConversationIds: Set; }; +type GatewayBridgeClaimResult = + | "claimed" + | "duplicate_request" + | "duplicate_client_request" + | "conversation_busy"; + +const GATEWAY_CHAT_RUNTIME_LEASE_MS = 15_000; +const GATEWAY_CHAT_RUNTIME_HEARTBEAT_MS = 5_000; +const GATEWAY_CHAT_RUNTIME_IDLE_POLL_MS = 5_000; +const GATEWAY_CHAT_RUNTIME_STATUS_HEARTBEAT_MS = 5_000; +const GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE = + "Another remote gateway chat request is already running."; + const gatewayBridgeRequestRegistry = (() => { const root = globalThis as typeof globalThis & { __LIVEAGENT_GATEWAY_BRIDGE_REQUESTS__?: GatewayBridgeRequestRegistry; @@ -43,7 +63,9 @@ const gatewayBridgeRequestRegistry = (() => { activeRequests: new Map(), pendingRequestIds: new Set(), pendingClientRequestIds: new Set(), + pendingConversationIds: new Set(), }; + root.__LIVEAGENT_GATEWAY_BRIDGE_REQUESTS__.pendingConversationIds ??= new Set(); return root.__LIVEAGENT_GATEWAY_BRIDGE_REQUESTS__; })(); @@ -57,6 +79,10 @@ function asErrorMessage(error: unknown, fallback: string) { return fallback; } +function isConversationAlreadyRunningError(message: string) { + return message.trim().startsWith("Conversation is already running:"); +} + export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParams) { const { currentConversationIdRef, @@ -75,21 +101,51 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam useEffect(() => { let disposed = false; - let unlistenChatRequest: (() => void) | null = null; + let unlistenChatRequestReady: (() => void) | null = null; let unlistenChatCancel: (() => void) | null = null; let unlistenHistoryTruncate: (() => void) | null = null; + let unlistenGatewayStatus: (() => void) | null = null; + let drainInFlight = false; + const workerId = + typeof crypto !== "undefined" && "randomUUID" in crypto + ? `gateway-chat-runtime-${crypto.randomUUID()}` + : `gateway-chat-runtime-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const heartbeatTimers = new Map(); + + const activeRuntimeRequestCount = () => + gatewayBridgeRequestRegistry.activeRequests.size + + gatewayBridgeRequestRegistry.pendingRequestIds.size; + + const runtimeVisible = () => + typeof document === "undefined" ? true : document.visibilityState !== "hidden"; + + const publishRuntimeHeartbeat = (state?: "ready" | "draining" | "busy" | "suspended") => { + const activeRunCount = activeRuntimeRequestCount(); + const nextState = state ?? (activeRunCount > 0 ? "busy" : "ready"); + void invoke("gateway_chat_runtime_heartbeat", { + worker_id: workerId, + state: nextState, + visible: runtimeVisible(), + active_run_count: activeRunCount, + } as any).catch((error) => { + console.warn("gateway_chat_runtime_heartbeat failed", error); + }); + }; const setActiveGatewayBridgeRequest = (request: ActiveGatewayBridgeRequest) => { gatewayBridgeRequestRegistry.pendingRequestIds.delete(request.requestId); if (request.clientRequestId) { gatewayBridgeRequestRegistry.pendingClientRequestIds.delete(request.clientRequestId); } + gatewayBridgeRequestRegistry.pendingConversationIds.delete(request.conversationId); gatewayBridgeRequestRegistry.activeRequests.set(request.requestId, request); + publishRuntimeHeartbeat("busy"); return request; }; const clearActiveGatewayBridgeRequest = (requestId: string) => { gatewayBridgeRequestRegistry.activeRequests.delete(requestId.trim()); + publishRuntimeHeartbeat(); }; const getActiveGatewayBridgeRequestByRequestId = (requestId: string) => { @@ -124,141 +180,398 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam return null; }; - const claimGatewayBridgeRequest = (requestId: string, clientRequestId: string) => { + const claimGatewayBridgeRequest = ( + requestId: string, + clientRequestId: string, + conversationId: string, + ): GatewayBridgeClaimResult => { + const targetConversationId = conversationId.trim(); if ( gatewayBridgeRequestRegistry.pendingRequestIds.has(requestId) || gatewayBridgeRequestRegistry.activeRequests.has(requestId) ) { - return false; + return "duplicate_request"; } if ( clientRequestId && (gatewayBridgeRequestRegistry.pendingClientRequestIds.has(clientRequestId) || getActiveGatewayBridgeRequestByClientRequestId(clientRequestId)) ) { - return false; + return "duplicate_client_request"; + } + if ( + targetConversationId && + (gatewayBridgeRequestRegistry.pendingConversationIds.has(targetConversationId) || + getActiveGatewayBridgeRequestByConversationId(targetConversationId)) + ) { + return "conversation_busy"; } gatewayBridgeRequestRegistry.pendingRequestIds.add(requestId); if (clientRequestId) { gatewayBridgeRequestRegistry.pendingClientRequestIds.add(clientRequestId); } - return true; + if (targetConversationId) { + gatewayBridgeRequestRegistry.pendingConversationIds.add(targetConversationId); + } + publishRuntimeHeartbeat("busy"); + return "claimed"; }; const releaseGatewayBridgeRequestClaim = ( requestId: string, clientRequestId: string, + conversationId: string, request: ActiveGatewayBridgeRequest | null, ) => { gatewayBridgeRequestRegistry.pendingRequestIds.delete(requestId); if (clientRequestId) { gatewayBridgeRequestRegistry.pendingClientRequestIds.delete(clientRequestId); } + if (conversationId) { + gatewayBridgeRequestRegistry.pendingConversationIds.delete(conversationId); + } if (request) { clearActiveGatewayBridgeRequest(request.requestId); } + publishRuntimeHeartbeat(); }; - void listen("gateway:chat-request", (event) => { - void (async () => { - const requestId = event.payload.requestId.trim(); - const clientRequestId = event.payload.clientRequestId?.trim() ?? ""; - const message = event.payload.message.trim(); - const uploadedFiles = Array.isArray(event.payload.uploadedFiles) - ? event.payload.uploadedFiles - : []; - const targetConversationId = event.payload.conversationId.trim(); - let resolvedConversationId = targetConversationId; - let gatewayBridgeRequest: ActiveGatewayBridgeRequest | null = null; - let claimedRequest = false; - - if (!requestId) { - return; - } - if (!message && uploadedFiles.length === 0) { - queueGatewayBridgeEventForRequest(requestId, { - type: "error", - message: "Remote chat message cannot be empty.", - conversation_id: targetConversationId, + const stopHeartbeat = (requestId: string) => { + const timer = heartbeatTimers.get(requestId); + if (timer !== undefined) { + window.clearInterval(timer); + heartbeatTimers.delete(requestId); + } + }; + + const startHeartbeat = (requestId: string) => { + stopHeartbeat(requestId); + publishRuntimeHeartbeat("busy"); + void invoke("gateway_chat_heartbeat", { + request_id: requestId, + worker_id: workerId, + } as any).catch((error) => { + console.warn("gateway_chat_heartbeat failed", error); + }); + heartbeatTimers.set( + requestId, + window.setInterval(() => { + void invoke("gateway_chat_heartbeat", { + request_id: requestId, + worker_id: workerId, + } as any).catch((error) => { + console.warn("gateway_chat_heartbeat failed", error); }); + }, GATEWAY_CHAT_RUNTIME_HEARTBEAT_MS), + ); + }; + + const failClaimedRequest = ( + requestId: string, + conversationId: string, + errorCode: string, + message: string, + ) => { + void invoke("gateway_chat_fail", { + request_id: requestId, + conversation_id: conversationId || undefined, + error_code: errorCode, + message, + terminal: true, + worker_id: workerId, + } as any).catch((error) => { + console.warn("gateway_chat_fail failed", error); + }); + }; + + const handleGatewayChatRequest = async (claimed: GatewayChatClaimedRequest) => { + const payload = claimed.request; + const requestId = payload.requestId.trim(); + const clientRequestId = payload.clientRequestId?.trim() ?? ""; + const message = payload.message.trim(); + const uploadedFiles = Array.isArray(payload.uploadedFiles) ? payload.uploadedFiles : []; + const targetConversationId = payload.conversationId.trim(); + let resolvedConversationId = targetConversationId; + let gatewayBridgeRequest: ActiveGatewayBridgeRequest | null = null; + let claimedRequest = false; + + if (!requestId) { + return; + } + startHeartbeat(requestId); + if (!message && uploadedFiles.length === 0) { + queueGatewayBridgeEventForRequest(requestId, { + type: "error", + message: "Remote chat message cannot be empty.", + conversation_id: targetConversationId, + }, { + workerId, + }); + failClaimedRequest( + requestId, + targetConversationId, + "empty_remote_message", + "Remote chat message cannot be empty.", + ); + stopHeartbeat(requestId); + return; + } + const claimResult = claimGatewayBridgeRequest( + requestId, + clientRequestId, + targetConversationId, + ); + if (claimResult !== "claimed") { + if (claimResult === "conversation_busy") { + queueGatewayBridgeEventForRequest( + requestId, + { + type: "error", + message: GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, + conversation_id: targetConversationId, + }, + { + workerId, + }, + ); + failClaimedRequest( + requestId, + targetConversationId, + "conversation_busy", + GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, + ); + stopHeartbeat(requestId); return; } - if (!claimGatewayBridgeRequest(requestId, clientRequestId)) { + void invoke("gateway_chat_release_lease", { + request_id: requestId, + worker_id: workerId, + } as any).catch((error) => { + console.warn("gateway_chat_release_lease failed", error); + }); + stopHeartbeat(requestId); + return; + } + claimedRequest = true; + + try { + const duplicateRequest = + getActiveGatewayBridgeRequestByRequestId(requestId) || + (clientRequestId + ? getActiveGatewayBridgeRequestByClientRequestId(clientRequestId) + : null); + if (duplicateRequest) { + void invoke("gateway_chat_release_lease", { + request_id: requestId, + worker_id: workerId, + } as any).catch((error) => { + console.warn("gateway_chat_release_lease failed", error); + }); return; } - claimedRequest = true; - - try { - resolvedConversationId = await ensureGatewayBridgeConversationReadyRef.current( - targetConversationId, + if ( + targetConversationId && + (isConversationRunning(targetConversationId) || + getConversationAbortController(targetConversationId)) + ) { + queueGatewayBridgeEventForRequest( + requestId, + { + type: "error", + message: GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, + conversation_id: targetConversationId, + }, { - forceHydrate: event.payload.forceHydrate === true, - historyTruncationKey: event.payload.historyTruncationKey, + workerId, }, ); + failClaimedRequest( + requestId, + targetConversationId, + "conversation_busy", + GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, + ); + return; + } - const runningRequest = - getActiveGatewayBridgeRequestByConversationId(resolvedConversationId) || - (clientRequestId - ? getActiveGatewayBridgeRequestByClientRequestId(clientRequestId) - : null); - if ( - runningRequest || - isConversationRunning(resolvedConversationId) || - getConversationAbortController(resolvedConversationId) - ) { - queueGatewayBridgeEventForRequest(requestId, { - type: "error", - message: "Another remote gateway chat request is already running.", - conversation_id: runningRequest?.conversationId || resolvedConversationId, - }); - return; - } + resolvedConversationId = await ensureGatewayBridgeConversationReadyRef.current( + targetConversationId, + { + forceHydrate: payload.forceHydrate === true, + historyTruncationKey: payload.historyTruncationKey, + }, + ); - gatewayBridgeRequest = setActiveGatewayBridgeRequest({ - requestId, - conversationId: resolvedConversationId, - clientRequestId: clientRequestId || undefined, - startedAt: Date.now(), - selectedModelOverride: event.payload.selectedModel, - runtimeControlsOverride: event.payload.runtimeControls - ? normalizeChatRuntimeControls(event.payload.runtimeControls) - : undefined, - executionModeOverride: normalizeGatewayExecutionMode(event.payload.executionMode), - workdirOverride: normalizeGatewayWorkdir(event.payload.workdir), - selectedSystemToolIdsOverride: normalizeSystemToolSelection( - event.payload.selectedSystemTools, - ), - }); - await sendActionRef.current({ - textOverride: message, - uploadedFilesOverride: uploadedFiles, - conversationIdOverride: resolvedConversationId, - executionModeOverride: gatewayBridgeRequest.executionModeOverride, - workdirOverride: gatewayBridgeRequest.workdirOverride, - selectedSystemToolIdsOverride: gatewayBridgeRequest.selectedSystemToolIdsOverride, - runtimeControlsOverride: gatewayBridgeRequest.runtimeControlsOverride, - gatewayBridgeRequestOverride: gatewayBridgeRequest, - }); - } catch (error) { + const runningRequest = + getActiveGatewayBridgeRequestByConversationId(resolvedConversationId) || + (clientRequestId ? getActiveGatewayBridgeRequestByClientRequestId(clientRequestId) : null); + if ( + runningRequest || + isConversationRunning(resolvedConversationId) || + getConversationAbortController(resolvedConversationId) + ) { queueGatewayBridgeEventForRequest(requestId, { type: "error", - message: asErrorMessage(error, "Failed to execute the remote gateway chat request."), - conversation_id: - resolvedConversationId || targetConversationId || currentConversationIdRef.current, + message: GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, + conversation_id: runningRequest?.conversationId || resolvedConversationId, + }, { + workerId, }); - } finally { - if (claimedRequest) { - releaseGatewayBridgeRequestClaim(requestId, clientRequestId, gatewayBridgeRequest); + failClaimedRequest( + requestId, + runningRequest?.conversationId || resolvedConversationId, + "conversation_busy", + GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE, + ); + return; + } + + gatewayBridgeRequest = setActiveGatewayBridgeRequest({ + requestId, + conversationId: resolvedConversationId, + clientRequestId: clientRequestId || undefined, + workerId, + startedAt: Date.now(), + selectedModelOverride: payload.selectedModel, + runtimeControlsOverride: payload.runtimeControls + ? normalizeChatRuntimeControls(payload.runtimeControls) + : undefined, + executionModeOverride: normalizeGatewayExecutionMode(payload.executionMode), + workdirOverride: normalizeGatewayWorkdir(payload.workdir), + selectedSystemToolIdsOverride: normalizeSystemToolSelection(payload.selectedSystemTools), + }); + await sendActionRef.current({ + textOverride: message, + uploadedFilesOverride: uploadedFiles, + conversationIdOverride: resolvedConversationId, + executionModeOverride: gatewayBridgeRequest.executionModeOverride, + workdirOverride: gatewayBridgeRequest.workdirOverride, + selectedSystemToolIdsOverride: gatewayBridgeRequest.selectedSystemToolIdsOverride, + runtimeControlsOverride: gatewayBridgeRequest.runtimeControlsOverride, + gatewayBridgeRequestOverride: gatewayBridgeRequest, + afterInitialHistoryPersist: async () => { + await invoke("gateway_chat_mark_started", { + request_id: requestId, + conversation_id: resolvedConversationId, + worker_id: workerId, + } as any); + }, + }); + await invoke("gateway_chat_complete", { + request_id: requestId, + conversation_id: resolvedConversationId, + worker_id: workerId, + } as any); + } catch (error) { + const rawMessage = asErrorMessage( + error, + "Failed to execute the remote gateway chat request.", + ); + const conversationBusy = isConversationAlreadyRunningError(rawMessage); + const message = conversationBusy + ? GATEWAY_CHAT_CONVERSATION_BUSY_MESSAGE + : rawMessage; + queueGatewayBridgeEventForRequest(requestId, { + type: "error", + message, + conversation_id: + resolvedConversationId || targetConversationId || currentConversationIdRef.current, + }, { + workerId, + }); + failClaimedRequest( + requestId, + resolvedConversationId || targetConversationId || currentConversationIdRef.current, + conversationBusy ? "conversation_busy" : "desktop_runtime_error", + message, + ); + } finally { + stopHeartbeat(requestId); + if (claimedRequest) { + releaseGatewayBridgeRequestClaim( + requestId, + clientRequestId, + resolvedConversationId || targetConversationId, + gatewayBridgeRequest, + ); + } + } + }; + + const drainGatewayChatInbox = async () => { + if (drainInFlight || disposed) { + return; + } + drainInFlight = true; + publishRuntimeHeartbeat("draining"); + try { + for (;;) { + if (disposed) { + return; } + const claimed = await invoke( + "gateway_chat_claim_next", + { + worker_id: workerId, + lease_ms: GATEWAY_CHAT_RUNTIME_LEASE_MS, + } as any, + ); + if (!claimed || disposed) { + return; + } + void handleGatewayChatRequest(claimed); } - })(); + } catch (error) { + console.warn("gateway_chat_claim_next failed", error); + } finally { + drainInFlight = false; + publishRuntimeHeartbeat(); + } + }; + + void listen("gateway:chat-request-ready", () => { + publishRuntimeHeartbeat("draining"); + void drainGatewayChatInbox(); + }).then((dispose) => { + if (disposed) { + dispose(); + return; + } + unlistenChatRequestReady = dispose; + publishRuntimeHeartbeat("ready"); + void drainGatewayChatInbox(); + }); + + const idlePollId = window.setInterval(() => { + publishRuntimeHeartbeat(); + void drainGatewayChatInbox(); + }, GATEWAY_CHAT_RUNTIME_IDLE_POLL_MS); + + const runtimeHeartbeatId = window.setInterval(() => { + publishRuntimeHeartbeat(); + }, GATEWAY_CHAT_RUNTIME_STATUS_HEARTBEAT_MS); + + const handleRuntimeWake = () => { + publishRuntimeHeartbeat("draining"); + void drainGatewayChatInbox(); + }; + + window.addEventListener("online", handleRuntimeWake); + window.addEventListener("focus", handleRuntimeWake); + window.addEventListener("pageshow", handleRuntimeWake); + document.addEventListener("visibilitychange", handleRuntimeWake); + document.addEventListener("resume", handleRuntimeWake); + + void listen>("gateway:status", (event) => { + if (event.payload?.online === true) { + handleRuntimeWake(); + } }).then((dispose) => { if (disposed) { dispose(); return; } - unlistenChatRequest = dispose; + unlistenGatewayStatus = dispose; }); void listen("gateway:chat-cancel", (event) => { @@ -341,9 +654,21 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam return () => { disposed = true; - unlistenChatRequest?.(); + window.clearInterval(idlePollId); + window.clearInterval(runtimeHeartbeatId); + window.removeEventListener("online", handleRuntimeWake); + window.removeEventListener("focus", handleRuntimeWake); + window.removeEventListener("pageshow", handleRuntimeWake); + document.removeEventListener("visibilitychange", handleRuntimeWake); + document.removeEventListener("resume", handleRuntimeWake); + publishRuntimeHeartbeat("suspended"); + for (const requestId of heartbeatTimers.keys()) { + stopHeartbeat(requestId); + } + unlistenChatRequestReady?.(); unlistenChatCancel?.(); unlistenHistoryTruncate?.(); + unlistenGatewayStatus?.(); }; }, [ conversationRuntimeCacheRef, diff --git a/crates/agent-gui/src/pages/chat/usePendingUploads.ts b/crates/agent-gui/src/pages/chat/usePendingUploads.ts index 4ab5a7d21..005bc0b77 100644 --- a/crates/agent-gui/src/pages/chat/usePendingUploads.ts +++ b/crates/agent-gui/src/pages/chat/usePendingUploads.ts @@ -63,6 +63,11 @@ export function usePendingUploads(params: UsePendingUploadsParams) { const activeUploadTasksRef = useRef(0); const uploadContextRef = useRef<{ isAgentMode: boolean; workdir: string } | null>(null); const pendingUploadsByConversationRef = useRef(new Map()); + const pendingUploadedFilesRef = useRef(pendingUploadedFiles); + + useEffect(() => { + pendingUploadedFilesRef.current = pendingUploadedFiles; + }, [pendingUploadedFiles]); const beginUploadTask = useCallback(() => { activeUploadTasksRef.current += 1; @@ -81,35 +86,117 @@ export function usePendingUploads(params: UsePendingUploadsParams) { if (!previous) return; if (previous.isAgentMode === isAgentMode && previous.workdir === workdir) return; pendingUploadsByConversationRef.current.clear(); + pendingUploadedFilesRef.current = []; setPendingUploadedFiles([]); }, [isAgentMode, workdir]); + const getPendingUploadsForConversation = useCallback( + (conversationId: string) => { + const targetConversationId = conversationId.trim(); + if ( + !targetConversationId || + currentConversationIdRef.current.trim() === targetConversationId + ) { + return pendingUploadedFilesRef.current; + } + return pendingUploadsByConversationRef.current.get(targetConversationId) ?? []; + }, + [currentConversationIdRef], + ); + + const setPendingUploadsForConversation = useCallback( + (conversationId: string, nextFiles: PendingUploadedFile[]) => { + const targetConversationId = conversationId.trim(); + const normalizedFiles = nextFiles.slice(); + if (targetConversationId) { + if (normalizedFiles.length > 0) { + pendingUploadsByConversationRef.current.set(targetConversationId, normalizedFiles); + } else { + pendingUploadsByConversationRef.current.delete(targetConversationId); + } + } + if ( + !targetConversationId || + currentConversationIdRef.current.trim() === targetConversationId + ) { + pendingUploadedFilesRef.current = normalizedFiles; + setPendingUploadedFiles(normalizedFiles); + } + }, + [currentConversationIdRef], + ); + + const captureUploadTarget = useCallback(() => { + const targetConversationId = currentConversationIdRef.current.trim(); + if (!targetConversationId) { + setErrorMessage("请先选择或创建会话后再上传文件。"); + return null; + } + + const currentTargetUploads = getPendingUploadsForConversation(targetConversationId); + setPendingUploadsForConversation(targetConversationId, currentTargetUploads); + const remainingFileSlots = Math.max(0, MAX_UPLOAD_FILES - currentTargetUploads.length); + if (remainingFileSlots === 0) { + addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); + return null; + } + + return { + targetConversationId, + targetWorkdir: workdir, + remainingFileSlots, + }; + }, [ + addNotify, + currentConversationIdRef, + getPendingUploadsForConversation, + setErrorMessage, + setPendingUploadsForConversation, + workdir, + ]); + const appendImportedFiles = useCallback( - (result: SystemPickReadableFilesResponse, emptySelectionMessage: string) => { + ( + conversationId: string, + result: SystemPickReadableFilesResponse, + emptySelectionMessage: string, + ) => { + const targetConversationId = conversationId.trim(); if (result.files.length === 0 && result.skipped.length === 0) { return; } if (result.files.length > 0) { - setPendingUploadedFiles((prev) => { - const merged = mergePendingUploadedFiles(prev, result.files); - if (merged.length > MAX_UPLOAD_FILES) { - addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); - } - const next = merged.slice(0, MAX_UPLOAD_FILES); - pendingUploadsByConversationRef.current.set(currentConversationIdRef.current, next); - return next; - }); - composerRef.current?.focus(); + const previous = getPendingUploadsForConversation(targetConversationId); + const merged = mergePendingUploadedFiles(previous, result.files); + if (merged.length > MAX_UPLOAD_FILES) { + addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); + } + const next = merged.slice(0, MAX_UPLOAD_FILES); + setPendingUploadsForConversation(targetConversationId, next); + if (currentConversationIdRef.current.trim() === targetConversationId) { + composerRef.current?.focus(); + } } if (result.files.length === 0 && result.skipped.length > 0) { - setErrorMessage(`${emptySelectionMessage}:\n${result.skipped.join("\n")}`); + if (currentConversationIdRef.current.trim() === targetConversationId) { + setErrorMessage(`${emptySelectionMessage}:\n${result.skipped.join("\n")}`); + } else { + addNotify("warning", `${emptySelectionMessage}:\n${result.skipped.join("\n")}`); + } return; } if (result.skipped.length > 0) { addNotify("warning", `以下文件已跳过:\n${result.skipped.join("\n")}`); } }, - [addNotify, composerRef, currentConversationIdRef, setErrorMessage], + [ + addNotify, + composerRef, + currentConversationIdRef, + getPendingUploadsForConversation, + setErrorMessage, + setPendingUploadsForConversation, + ], ); const pickReadableFiles = useCallback(async () => { @@ -126,22 +213,26 @@ export function usePendingUploads(params: UsePendingUploadsParams) { return; } - const remainingFileSlots = Math.max(0, MAX_UPLOAD_FILES - pendingUploadedFiles.length); - if (remainingFileSlots === 0) { - addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); + const uploadTarget = captureUploadTarget(); + if (!uploadTarget) { return; } + const { targetConversationId, targetWorkdir, remainingFileSlots } = uploadTarget; const finishUploadTask = beginUploadTask(); try { const result = await invoke("system_pick_readable_files", { - workdir, + workdir: targetWorkdir, maxFiles: remainingFileSlots, }); - appendImportedFiles(result, "所选文件均不受当前 Read 支持"); + appendImportedFiles(targetConversationId, result, "所选文件均不受当前 Read 支持"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - setErrorMessage(message || "导入文件失败"); + if (currentConversationIdRef.current.trim() === targetConversationId) { + setErrorMessage(message || "导入文件失败"); + } else { + addNotify("warning", message || "导入文件失败"); + } } finally { finishUploadTask(); } @@ -149,8 +240,9 @@ export function usePendingUploads(params: UsePendingUploadsParams) { addNotify, appendImportedFiles, beginUploadTask, + captureUploadTarget, + currentConversationIdRef, isAgentMode, - pendingUploadedFiles.length, setErrorMessage, workdir, ]); @@ -171,22 +263,26 @@ export function usePendingUploads(params: UsePendingUploadsParams) { return; } - const remainingFileSlots = Math.max(0, MAX_UPLOAD_FILES - pendingUploadedFiles.length); - if (remainingFileSlots === 0) { - addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); + const uploadTarget = captureUploadTarget(); + if (!uploadTarget) { return; } + const { targetConversationId, targetWorkdir, remainingFileSlots } = uploadTarget; const finishUploadTask = beginUploadTask(); try { const result = await invoke( "system_import_readable_file_paths", - { workdir, paths, maxFiles: remainingFileSlots }, + { workdir: targetWorkdir, paths, maxFiles: remainingFileSlots }, ); - appendImportedFiles(result, "拖入文件均不受当前 Read 支持"); + appendImportedFiles(targetConversationId, result, "拖入文件均不受当前 Read 支持"); } catch (error) { const message = error instanceof Error ? error.message : String(error); - setErrorMessage(message || "导入文件失败"); + if (currentConversationIdRef.current.trim() === targetConversationId) { + setErrorMessage(message || "导入文件失败"); + } else { + addNotify("warning", message || "导入文件失败"); + } } finally { finishUploadTask(); } @@ -195,8 +291,9 @@ export function usePendingUploads(params: UsePendingUploadsParams) { addNotify, appendImportedFiles, beginUploadTask, + captureUploadTarget, + currentConversationIdRef, isAgentMode, - pendingUploadedFiles.length, setErrorMessage, workdir, ], @@ -218,11 +315,11 @@ export function usePendingUploads(params: UsePendingUploadsParams) { return; } - const remainingFileSlots = Math.max(0, MAX_UPLOAD_FILES - pendingUploadedFiles.length); - if (remainingFileSlots === 0) { - addNotify("warning", `最多上传 ${MAX_UPLOAD_FILES} 个文件,已忽略多余文件`); + const uploadTarget = captureUploadTarget(); + if (!uploadTarget) { return; } + const { targetConversationId, targetWorkdir, remainingFileSlots } = uploadTarget; const importBatch = files.slice(0, remainingFileSlots); const ignoredForLimit = files.length - importBatch.length; @@ -231,9 +328,9 @@ export function usePendingUploads(params: UsePendingUploadsParams) { const uploadFiles = await Promise.all(importBatch.map(fileToUploadInput)); const result = await invoke( "system_import_uploaded_readable_files", - { workdir, files: uploadFiles, maxFiles: remainingFileSlots }, + { workdir: targetWorkdir, files: uploadFiles, maxFiles: remainingFileSlots }, ); - appendImportedFiles(result, "剪贴板文件均不受当前 Read 支持"); + appendImportedFiles(targetConversationId, result, "剪贴板文件均不受当前 Read 支持"); if (ignoredForLimit > 0) { addNotify( "warning", @@ -242,7 +339,11 @@ export function usePendingUploads(params: UsePendingUploadsParams) { } } catch (error) { const message = error instanceof Error ? error.message : String(error); - setErrorMessage(message || "导入剪贴板文件失败"); + if (currentConversationIdRef.current.trim() === targetConversationId) { + setErrorMessage(message || "导入剪贴板文件失败"); + } else { + addNotify("warning", message || "导入剪贴板文件失败"); + } } finally { finishUploadTask(); } @@ -251,8 +352,9 @@ export function usePendingUploads(params: UsePendingUploadsParams) { addNotify, appendImportedFiles, beginUploadTask, + captureUploadTarget, + currentConversationIdRef, isAgentMode, - pendingUploadedFiles.length, setErrorMessage, workdir, ], @@ -260,17 +362,13 @@ export function usePendingUploads(params: UsePendingUploadsParams) { const removePendingUpload = useCallback( (relativePath: string) => { - setPendingUploadedFiles((prev) => { - const next = prev.filter((file) => file.relativePath !== relativePath); - if (next.length > 0) { - pendingUploadsByConversationRef.current.set(currentConversationIdRef.current, next); - } else { - pendingUploadsByConversationRef.current.delete(currentConversationIdRef.current); - } - return next; - }); + const targetConversationId = currentConversationIdRef.current.trim(); + const next = getPendingUploadsForConversation(targetConversationId).filter( + (file) => file.relativePath !== relativePath, + ); + setPendingUploadsForConversation(targetConversationId, next); }, - [currentConversationIdRef], + [currentConversationIdRef, getPendingUploadsForConversation, setPendingUploadsForConversation], ); return { diff --git a/crates/agent-gui/src/pages/settings/RemoteSection.tsx b/crates/agent-gui/src/pages/settings/RemoteSection.tsx index f4ad670a0..54a2e710f 100644 --- a/crates/agent-gui/src/pages/settings/RemoteSection.tsx +++ b/crates/agent-gui/src/pages/settings/RemoteSection.tsx @@ -518,6 +518,24 @@ export function RemoteSection(props: SettingsSectionProps) { } />
+ +
+
+
{t("settings.remoteWebTunnels")}
+

+ {t("settings.remoteWebTunnelsHint")} +

+
+ + updateRemoteSettings(setSettings, { + enableWebTunnels: !settings.remote.enableWebTunnels, + }) + } + /> +
diff --git a/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs b/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs index d9c58b5e6..6b8595e42 100644 --- a/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs +++ b/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs @@ -12,9 +12,14 @@ function createController(options = {}) { const controller = createGatewayBridgeEventController({ conversationId: options.conversationId ?? "conversation-1", requestId: options.requestId ?? "request-1", + workerId: options.workerId, enabled: options.enabled ?? true, - sendEvent: (requestId, event) => { - sent.push({ requestId, event }); + sendEvent: (requestId, event, sendOptions) => { + const item = { requestId, event }; + if (sendOptions?.workerId) { + item.options = sendOptions; + } + sent.push(item); }, resolveErrorConversationId: options.resolveErrorConversationId, }); @@ -69,6 +74,42 @@ test("gateway bridge token forwarding tracks non-empty text only", () => { }); }); +test("gateway bridge started event is explicit and does not mark text forwarded", () => { + const { controller, sent } = createController(); + + controller.queueStarted(); + + assert.equal(controller.hasForwardedText(), false); + assert.deepEqual(sent, [ + { + requestId: "request-1", + event: { + type: "started", + conversation_id: "conversation-1", + }, + }, + ]); +}); + +test("gateway bridge events carry the remote worker lease owner", () => { + const { controller, sent } = createController({ workerId: "worker-1" }); + + controller.queueStarted(); + + assert.deepEqual(sent, [ + { + requestId: "request-1", + event: { + type: "started", + conversation_id: "conversation-1", + }, + options: { + workerId: "worker-1", + }, + }, + ]); +}); + test("gateway bridge tool status is normalized and de-duplicated", () => { const { controller, sent } = createController(); diff --git a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs index da263a3ed..5929c8508 100644 --- a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs +++ b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs @@ -30,6 +30,11 @@ const loader = createTsModuleLoader({ throw new Error("openUrl mock was not expected to be called"); }, }, + "react-dom": { + createPortal(children, container) { + return { type: "portal", children, container }; + }, + }, "./ui/button": { Button(props) { return { type: "Button", props }; @@ -123,6 +128,34 @@ test("markdown image syntax falls back to alt text instead of rendering a real i assert.equal(empty, null); }); +test("external link safety modal renders through document body portal", () => { + const previousDocument = globalThis.document; + const body = { nodeType: 1 }; + globalThis.document = { body }; + + try { + const portal = markdownModule.ExternalLinkModal({ + isOpen: true, + onClose() {}, + onConfirm() {}, + url: "https://example.com/dashboard", + }); + + assert.ok(portal); + assert.equal(portal.type, "portal"); + assert.equal(portal.container, body); + assert.equal(portal.children.type, "div"); + assert.match(portal.children.props.className, /\bfixed\b/); + assert.match(portal.children.props.className, /\binset-0\b/); + } finally { + if (typeof previousDocument === "undefined") { + delete globalThis.document; + } else { + globalThis.document = previousDocument; + } + } +}); + test("agent tool rules require Image for chat-visible images", () => { const suffix = agentRunnerModule.buildToolsSuffix("/workspace"); assert.match(suffix, /To display any image in the chat UI, call the Image tool\./); diff --git a/crates/agent-gui/test/chat/model-selection.test.mjs b/crates/agent-gui/test/chat/model-selection.test.mjs new file mode 100644 index 000000000..dbd627a4e --- /dev/null +++ b/crates/agent-gui/test/chat/model-selection.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const settings = loader.loadModule("src/lib/settings/index.ts"); +const modelSelection = loader.loadModule("src/pages/chat/modelSelection.ts"); + +function provider(overrides = {}) { + const id = overrides.id ?? "provider-1"; + const type = overrides.type ?? "codex"; + const models = overrides.models ?? ["gpt-5"]; + const activeModels = overrides.activeModels ?? models; + return { + id, + name: id, + type, + baseUrl: overrides.baseUrl ?? "https://api.example.com/v1", + apiKey: "key", + models, + activeModels, + requestFormat: type === "codex" ? "openai-responses" : undefined, + }; +} + +function appSettings(customProviders, selectedModel) { + return settings.normalizeSettings({ + customProviders, + selectedModel, + }); +} + +test("local chat model selection resolves only an enabled selected model", () => { + const app = appSettings( + [provider({ id: "openai-main", models: ["gpt-5", "gpt-5-mini"] })], + { customProviderId: "openai-main", model: "gpt-5" }, + ); + + const resolved = modelSelection.resolveEffectiveChatModelSelection(app); + + assert.equal(resolved.provider.id, "openai-main"); + assert.equal(resolved.providerId, "codex"); + assert.equal(resolved.model, "gpt-5"); + assert.deepEqual(resolved.selectedModel, { + customProviderId: "openai-main", + model: "gpt-5", + }); +}); + +test("remote chat model selection does not fall back to another provider with the same type", () => { + const app = appSettings( + [ + provider({ id: "openai-main", models: ["gpt-5"] }), + provider({ id: "openai-backup", models: ["gpt-5-mini"] }), + ], + { customProviderId: "openai-main", model: "gpt-5" }, + ); + + assert.throws( + () => + modelSelection.resolveEffectiveChatModelSelection(app, { + customProviderId: "missing-openai", + model: "gpt-5-mini", + providerType: "codex", + }), + /供应商不存在/, + ); +}); + +test("remote chat model selection rejects provider type drift", () => { + const app = appSettings( + [provider({ id: "anthropic-main", type: "claude_code", models: ["claude-sonnet"] })], + { customProviderId: "anthropic-main", model: "claude-sonnet" }, + ); + + assert.throws( + () => + modelSelection.resolveEffectiveChatModelSelection(app, { + customProviderId: "anthropic-main", + model: "claude-sonnet", + providerType: "codex", + }), + /供应商类型.*不一致/, + ); +}); + +test("remote chat model selection rejects models that are no longer enabled", () => { + const app = appSettings( + [ + provider({ + id: "openai-main", + models: ["gpt-5", "gpt-5-mini"], + activeModels: ["gpt-5"], + }), + ], + { customProviderId: "openai-main", model: "gpt-5" }, + ); + + assert.throws( + () => + modelSelection.resolveEffectiveChatModelSelection(app, { + customProviderId: "openai-main", + model: "gpt-5-mini", + providerType: "codex", + }), + /未在桌面端启用/, + ); +}); + +test("remote chat model selection accepts an exact enabled provider model", () => { + const app = appSettings( + [provider({ id: "gemini-main", type: "gemini", models: ["gemini-3.5-flash"] })], + { customProviderId: "gemini-main", model: "gemini-3.5-flash" }, + ); + + const resolved = modelSelection.resolveEffectiveChatModelSelection(app, { + customProviderId: "gemini-main", + model: "gemini-3.5-flash", + providerType: "gemini", + }); + + assert.equal(resolved.provider.id, "gemini-main"); + assert.equal(resolved.providerId, "gemini"); + assert.deepEqual(resolved.selectedModel, { + customProviderId: "gemini-main", + model: "gemini-3.5-flash", + }); +}); diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index b65186a33..fda707545 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -460,6 +460,18 @@ test("gateway settings sync payload redacts provider api keys", () => { }, customSettings: { conversationTitleModel: { customProviderId: "provider-1", model: "gpt-5" }, + projectToolsPanel: { + width: 612, + activeTab: "tunnel", + activeTabs: { + " /workspace/a ": "tunnel", + "/workspace/b": "gitReview", + " ": "terminal", + }, + tabOrders: { + "/workspace/a": ["__tunnel__", "__file_tree__"], + }, + }, projectToolsFileTree: { openProjectPathKeys: ["/workspace/b", " ", "/workspace/a", "/workspace/a"], projects: { @@ -475,6 +487,10 @@ test("gateway settings sync payload redacts provider api keys", () => { openProjectPathKeys: ["/workspace/b", "/workspace/a", "/workspace/a"], openVersion: 2, }, + projectToolsTunnel: { + openProjectPathKeys: ["/workspace/b", "/workspace/a", "/workspace/a"], + openVersion: 3, + }, }, }); @@ -507,6 +523,10 @@ test("gateway settings sync payload redacts provider api keys", () => { openProjectPathKeys: ["/workspace/a", "/workspace/b"], openVersion: 2, }); + assert.deepEqual(payload.customSettings.projectToolsTunnel, { + openProjectPathKeys: ["/workspace/a", "/workspace/b"], + openVersion: 3, + }); assert.equal(Object.hasOwn(payload.customSettings, "projectToolsPanel"), false); assert.deepEqual(payload.chatRuntimeControls, appSettings.chatRuntimeControls); assert.equal(payload.providerApiKeyUpdates, undefined); @@ -628,6 +648,11 @@ test("normalizes project tools panel from current and legacy terminal panel sett projectToolsPanel: { width: 544, activeTab: "terminal", + activeTabs: { + " /workspace/app ": "gitReview", + "/workspace/other": "invalid", + " ": "terminal", + }, tabOrders: { " /workspace/app ": [ "terminal-2", @@ -645,11 +670,38 @@ test("normalizes project tools panel from current and legacy terminal panel sett assert.equal(currentShape.customSettings.projectToolsPanel.width, 544); assert.equal(currentShape.customSettings.projectToolsPanel.activeTab, "terminal"); + assert.deepEqual(currentShape.customSettings.projectToolsPanel.activeTabs, { + "/workspace/app": "gitReview", + }); assert.deepEqual(currentShape.customSettings.projectToolsPanel.tabOrders, { "/workspace/app": ["terminal-2", "terminal-1", "__file_tree__"], }); }); +test("updates project tools panel active tab per project", () => { + const base = settings.normalizeSettings({ + customSettings: { + projectToolsPanel: { + activeTab: "terminal", + }, + }, + }); + const updated = settings.updateProjectToolsPanelActiveTab(base, "/workspace/app", "gitReview"); + + assert.equal(updated.customSettings.projectToolsPanel.activeTab, "gitReview"); + assert.equal( + settings.getProjectToolsPanelActiveTab(updated.customSettings, "/workspace/app"), + "gitReview", + ); + assert.equal( + settings.getProjectToolsPanelActiveTab(updated.customSettings, "/workspace/other"), + "gitReview", + ); + assert.deepEqual(updated.customSettings.projectToolsPanel.activeTabs, { + "/workspace/app": "gitReview", + }); +}); + test("updates project tools panel tab order per project", () => { const base = settings.normalizeSettings({}); const updated = settings.updateProjectToolsPanelTabOrder(base, "/workspace/app", [ @@ -723,6 +775,10 @@ test("settings reload preserves session-only project tools state", () => { openProjectPathKeys: ["/workspace/app"], openVersion: 5, }, + projectToolsTunnel: { + openProjectPathKeys: ["/workspace/app"], + openVersion: 6, + }, }, }); const reloaded = settings.normalizeSettings({ @@ -737,6 +793,7 @@ test("settings reload preserves session-only project tools state", () => { }, projectToolsFileTree: {}, projectToolsGitReview: {}, + projectToolsTunnel: {}, }, }); @@ -762,6 +819,10 @@ test("settings reload preserves session-only project tools state", () => { "/workspace/app", ]); assert.equal(merged.customSettings.projectToolsGitReview.openVersion, 5); + assert.deepEqual(merged.customSettings.projectToolsTunnel.openProjectPathKeys, [ + "/workspace/app", + ]); + assert.equal(merged.customSettings.projectToolsTunnel.openVersion, 6); const loadedWithProjectTools = settings.normalizeSettings({ customSettings: { @@ -773,6 +834,10 @@ test("settings reload preserves session-only project tools state", () => { openProjectPathKeys: ["/loaded/project"], openVersion: 1, }, + projectToolsTunnel: { + openProjectPathKeys: ["/loaded/project"], + openVersion: 1, + }, }, }); const emptyCurrent = settings.normalizeSettings({}); @@ -787,6 +852,9 @@ test("settings reload preserves session-only project tools state", () => { assert.deepEqual(loadedOnly.customSettings.projectToolsGitReview.openProjectPathKeys, [ "/loaded/project", ]); + assert.deepEqual(loadedOnly.customSettings.projectToolsTunnel.openProjectPathKeys, [ + "/loaded/project", + ]); }); test("removes project tools state when a workspace project is deleted", () => { @@ -794,6 +862,10 @@ test("removes project tools state when a workspace project is deleted", () => { customSettings: { projectToolsPanel: { activeTab: "fileTree", + activeTabs: { + "/workspace/app": "fileTree", + "/workspace/other": "gitReview", + }, tabOrders: { "/workspace/app": ["terminal-a", "__file_tree__"], "/workspace/other": ["terminal-b"], @@ -823,11 +895,18 @@ test("removes project tools state when a workspace project is deleted", () => { openProjectPathKeys: ["/workspace/app", "/workspace/other"], openVersion: 5, }, + projectToolsTunnel: { + openProjectPathKeys: ["/workspace/app", "/workspace/other"], + openVersion: 6, + }, }, }); const cleaned = settings.removeProjectToolsProjectState(base, "/workspace/app"); + assert.deepEqual(cleaned.customSettings.projectToolsPanel.activeTabs, { + "/workspace/other": "gitReview", + }); assert.deepEqual(cleaned.customSettings.projectToolsPanel.tabOrders, { "/workspace/other": ["terminal-b"], }); @@ -842,6 +921,10 @@ test("removes project tools state when a workspace project is deleted", () => { "/workspace/other", ]); assert.equal(cleaned.customSettings.projectToolsGitReview.openVersion, 6); + assert.deepEqual(cleaned.customSettings.projectToolsTunnel.openProjectPathKeys, [ + "/workspace/other", + ]); + assert.equal(cleaned.customSettings.projectToolsTunnel.openVersion, 7); assert.equal(settings.removeProjectToolsProjectState(cleaned, "/workspace/app"), cleaned); const projectOnlyState = settings.normalizeSettings({ @@ -880,14 +963,32 @@ test("removes project tools state when a workspace project is deleted", () => { ); assert.equal(gitReviewOnlyCleaned.customSettings.projectToolsGitReview.openVersion, 10); assert.deepEqual(gitReviewOnlyCleaned.customSettings.projectToolsGitReview.openProjectPathKeys, []); + + const tunnelOnlyState = settings.normalizeSettings({ + customSettings: { + projectToolsTunnel: { + openProjectPathKeys: ["/workspace/app"], + openVersion: 11, + }, + }, + }); + const tunnelOnlyCleaned = settings.removeProjectToolsProjectState( + tunnelOnlyState, + "/workspace/app", + ); + assert.equal(tunnelOnlyCleaned.customSettings.projectToolsTunnel.openVersion, 12); + assert.deepEqual(tunnelOnlyCleaned.customSettings.projectToolsTunnel.openProjectPathKeys, []); }); -test("gateway settings sync keeps project tools panel state local", () => { +test("gateway settings sync keeps project tools panel local and syncs project tool state", () => { const current = settings.normalizeSettings({ customSettings: { projectToolsPanel: { width: 612, activeTab: "terminal", + activeTabs: { + "/desktop/project": "terminal", + }, tabOrders: { "/desktop/project": ["desktop-terminal", "__file_tree__"], }, @@ -908,6 +1009,10 @@ test("gateway settings sync keeps project tools panel state local", () => { openProjectPathKeys: ["/desktop/project"], openVersion: 1, }, + projectToolsTunnel: { + openProjectPathKeys: ["/desktop/project"], + openVersion: 1, + }, }, }); const incoming = settings.normalizeSettings({ @@ -915,6 +1020,9 @@ test("gateway settings sync keeps project tools panel state local", () => { projectToolsPanel: { width: 360, activeTab: "fileTree", + activeTabs: { + "/web/project": "fileTree", + }, tabOrders: { "/web/project": ["web-terminal", "__file_tree__"], }, @@ -935,25 +1043,23 @@ test("gateway settings sync keeps project tools panel state local", () => { openProjectPathKeys: ["/web/project"], openVersion: 2, }, + projectToolsTunnel: { + openProjectPathKeys: ["/web/project"], + openVersion: 2, + }, }, }); const payload = sync.buildGatewaySettingsSyncPayload(incoming); assert.equal(Object.hasOwn(payload.customSettings, "projectToolsPanel"), false); - const synced = sync.applyGatewaySettingsSyncPayload(current, { - ...payload, - customSettings: { - ...payload.customSettings, - projectToolsPanel: { - width: 360, - activeTab: "fileTree", - }, - }, - }); + const synced = sync.applyGatewaySettingsSyncPayload(current, payload); assert.equal(synced.customSettings.projectToolsPanel.width, 612); assert.equal(synced.customSettings.projectToolsPanel.activeTab, "terminal"); + assert.deepEqual(synced.customSettings.projectToolsPanel.activeTabs, { + "/desktop/project": "terminal", + }); assert.deepEqual(synced.customSettings.projectToolsPanel.tabOrders, { "/desktop/project": ["desktop-terminal", "__file_tree__"], }); @@ -971,10 +1077,44 @@ test("gateway settings sync keeps project tools panel state local", () => { "/web/project", ]); assert.equal(synced.customSettings.projectToolsGitReview.openVersion, 2); + assert.deepEqual(synced.customSettings.projectToolsTunnel.openProjectPathKeys, [ + "/web/project", + ]); + assert.equal(synced.customSettings.projectToolsTunnel.openVersion, 2); + + const legacyPanelSynced = sync.applyGatewaySettingsSyncPayload(current, { + ...payload, + customSettings: { + ...payload.customSettings, + projectToolsPanel: { + width: 360, + activeTab: "fileTree", + activeTabs: { + "/web/project": "fileTree", + }, + tabOrders: { + "/web/project": ["web-terminal", "__file_tree__"], + }, + }, + }, + }); + assert.equal(legacyPanelSynced.customSettings.projectToolsPanel.width, 612); + assert.equal(legacyPanelSynced.customSettings.projectToolsPanel.activeTab, "terminal"); + assert.deepEqual(legacyPanelSynced.customSettings.projectToolsPanel.activeTabs, { + "/desktop/project": "terminal", + }); + assert.deepEqual(legacyPanelSynced.customSettings.projectToolsPanel.tabOrders, { + "/desktop/project": ["desktop-terminal", "__file_tree__"], + }); + assert.deepEqual(legacyPanelSynced.customSettings.projectToolsFileTree.openProjectPathKeys, [ + "/web/project", + ]); const { projectToolsFileTree: _projectToolsFileTree, projectToolsGitReview: _projectToolsGitReview, + projectToolsTunnel: _projectToolsTunnel, + projectToolsPanel: _projectToolsPanel, ...legacyCustomSettings } = payload.customSettings; const legacySynced = sync.applyGatewaySettingsSyncPayload(current, { @@ -984,6 +1124,7 @@ test("gateway settings sync keeps project tools panel state local", () => { assert.deepEqual(legacySynced.customSettings.projectToolsFileTree.openProjectPathKeys, [ "/desktop/project", ]); + assert.equal(legacySynced.customSettings.projectToolsPanel.activeTab, "terminal"); assert.deepEqual(legacySynced.customSettings.projectToolsFileTree.projects["/desktop/project"], { query: "desktop", selectedPath: "desktop.ts", @@ -995,6 +1136,10 @@ test("gateway settings sync keeps project tools panel state local", () => { "/desktop/project", ]); assert.equal(legacySynced.customSettings.projectToolsGitReview.openVersion, 1); + assert.deepEqual(legacySynced.customSettings.projectToolsTunnel.openProjectPathKeys, [ + "/desktop/project", + ]); + assert.equal(legacySynced.customSettings.projectToolsTunnel.openVersion, 1); }); test("gateway settings sync ignores stale project file tree UI snapshots", () => { @@ -1172,6 +1317,65 @@ test("gateway settings sync ignores stale project git review open snapshots", () assert.equal(deletedProjectSynced.customSettings.projectToolsGitReview.openVersion, 5); }); +test("gateway settings sync ignores stale project tunnel open snapshots", () => { + const current = settings.normalizeSettings({ + customSettings: { + projectToolsTunnel: { + openProjectPathKeys: ["/workspace/app"], + openVersion: 2, + }, + }, + }); + + const staleSynced = sync.applyGatewaySettingsSyncPayload(current, { + customSettings: { + projectToolsTunnel: { + openProjectPathKeys: [], + openVersion: 1, + }, + }, + }); + + assert.deepEqual(staleSynced.customSettings.projectToolsTunnel.openProjectPathKeys, [ + "/workspace/app", + ]); + assert.equal(staleSynced.customSettings.projectToolsTunnel.openVersion, 2); + + const newerSynced = sync.applyGatewaySettingsSyncPayload(staleSynced, { + customSettings: { + projectToolsTunnel: { + openProjectPathKeys: [], + openVersion: 3, + }, + }, + }); + + assert.deepEqual(newerSynced.customSettings.projectToolsTunnel.openProjectPathKeys, []); + assert.equal(newerSynced.customSettings.projectToolsTunnel.openVersion, 3); + + const deletedProjectLocal = settings.removeProjectToolsProjectState( + settings.normalizeSettings({ + customSettings: { + projectToolsTunnel: { + openProjectPathKeys: ["/workspace/deleted"], + openVersion: 4, + }, + }, + }), + "/workspace/deleted", + ); + const deletedProjectSynced = sync.applyGatewaySettingsSyncPayload(deletedProjectLocal, { + customSettings: { + projectToolsTunnel: { + openProjectPathKeys: ["/workspace/deleted"], + openVersion: 4, + }, + }, + }); + assert.deepEqual(deletedProjectSynced.customSettings.projectToolsTunnel.openProjectPathKeys, []); + assert.equal(deletedProjectSynced.customSettings.projectToolsTunnel.openVersion, 5); +}); + test("gateway settings sync keeps newer project conversation activity", () => { const current = settings.normalizeSettings({ system: { diff --git a/crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs b/crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs new file mode 100644 index 000000000..78ef1b29d --- /dev/null +++ b/crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +function createTunnel(overrides = {}) { + return { + id: "tun-1", + slug: "abc123", + name: "Local app", + targetUrl: "http://localhost:3000", + publicUrl: "https://gateway.example.test/t/abc123", + createdAt: 1_700_000_000, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + activeConnections: 0, + status: "active", + ...overrides, + }; +} + +function createToolCall(args) { + return { + type: "toolCall", + id: "call-tunnel", + name: "TunnelManager", + arguments: args, + }; +} + +async function buildRegistry(params = {}) { + const loader = createTsModuleLoader(); + const { buildBuiltinToolRegistry } = loader.loadModule("src/lib/tools/builtinRegistry.ts"); + const { createFileToolState } = loader.loadModule("src/lib/tools/fileToolState.ts"); + return buildBuiltinToolRegistry({ + workdir: "/workspace", + providerId: "codex", + fileState: createFileToolState(), + skillsEnabled: false, + runtimeScope: "chat", + currentChatModel: { customProviderId: "p", model: "m" }, + selectedSystemToolIds: [], + mcpSettings: { selected: [], servers: [] }, + enabledMcpServerIds: [], + selectableMcpServers: [], + ...params, + }); +} + +test("TunnelManager is injected only when Remote Web Tunnels are enabled and gateway is online", async () => { + const disabledRegistry = await buildRegistry({ + remoteWebTunnelsEnabled: false, + remoteGatewayOnline: true, + }); + assert.equal(disabledRegistry.hasTool("TunnelManager"), false); + + const offlineRegistry = await buildRegistry({ + remoteWebTunnelsEnabled: true, + remoteGatewayOnline: false, + }); + assert.equal(offlineRegistry.hasTool("TunnelManager"), false); + + const enabledRegistry = await buildRegistry({ + remoteWebTunnelsEnabled: true, + remoteGatewayOnline: true, + }); + assert.equal(enabledRegistry.hasTool("TunnelManager"), true); + assert.equal( + enabledRegistry.metadataByName.get("TunnelManager").kind, + "tunnel_manager", + ); + + const cronRegistry = await buildRegistry({ + runtimeScope: "cron_auto_prompt", + remoteWebTunnelsEnabled: true, + remoteGatewayOnline: true, + }); + assert.equal(cronRegistry.hasTool("TunnelManager"), false); +}); + +test("TunnelManager list/create/close call gateway tunnel commands", async () => { + const invocations = []; + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + invocations.push({ command, args }); + if (command === "gateway_tunnel_list") { + return [createTunnel()]; + } + if (command === "gateway_tunnel_create") { + return createTunnel({ + id: "tun-created", + slug: "created", + targetUrl: args.input.targetUrl, + name: args.input.name ?? "", + ...(args.input.ttlSeconds === 0 ? { expiresAt: 0 } : {}), + }); + } + if (command === "gateway_tunnel_close") { + return createTunnel({ + id: args.tunnel_id, + status: "expired", + }); + } + throw new Error(`unexpected invoke ${command}`); + }, + }, + }, + }); + const { createTunnelManagerTools } = loader.loadModule("src/lib/tools/tunnelManagerTools.ts"); + const changes = []; + const bundle = createTunnelManagerTools({ + enabled: true, + runtimeScope: "chat", + projectPathKey: "project:/workspace", + onTunnelsChanged: (change) => changes.push(change), + }); + + assert.deepEqual(bundle.tools.map((tool) => tool.name), ["TunnelManager"]); + + const listResult = await bundle.executeToolCall(createToolCall({ action: "list" })); + assert.equal(listResult.isError, false); + assert.equal(listResult.details.kind, "tunnel_manager"); + assert.equal(listResult.details.tunnels.length, 1); + assert.equal(listResult.details.tunnels[0].activeConnections, undefined); + assert.doesNotMatch(listResult.content[0].text, /activeConnections|connections/i); + + const createResult = await bundle.executeToolCall( + createToolCall({ + action: "create", + targetUrl: "http://localhost:5173/app", + name: "Vite", + ttlSeconds: 0, + }), + ); + assert.equal(createResult.isError, false); + assert.equal(createResult.details.tunnel.id, "tun-created"); + assert.equal(createResult.details.tunnel.activeConnections, undefined); + assert.doesNotMatch(createResult.content[0].text, /activeConnections|connections/i); + assert.match(createResult.content[0].text, /unlimited/); + + const closeBySlugResult = await bundle.executeToolCall( + createToolCall({ action: "close", slug: "abc123" }), + ); + assert.equal(closeBySlugResult.isError, false); + assert.equal(closeBySlugResult.details.tunnel.activeConnections, undefined); + + assert.deepEqual( + invocations.map((call) => [call.command, call.args]), + [ + ["gateway_tunnel_list", undefined], + [ + "gateway_tunnel_create", + { + input: { + targetUrl: "http://localhost:5173/app", + name: "Vite", + ttlSeconds: 0, + projectPathKey: "project:/workspace", + }, + }, + ], + ["gateway_tunnel_list", undefined], + ["gateway_tunnel_close", { tunnel_id: "tun-1" }], + ], + ); + assert.deepEqual( + changes.map((change) => [change.action, change.tunnel.id, change.tunnel.activeConnections]), + [ + ["create", "tun-created", undefined], + ["close", "tun-1", undefined], + ], + ); +}); + +test("TunnelManager rejects invalid arguments before invoking gateway commands", async () => { + const invocations = []; + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + invocations.push({ command, args }); + throw new Error("unexpected invoke"); + }, + }, + }, + }); + const { createTunnelManagerTools } = loader.loadModule("src/lib/tools/tunnelManagerTools.ts"); + const bundle = createTunnelManagerTools({ enabled: true, runtimeScope: "chat" }); + + const invalidAction = await bundle.executeToolCall(createToolCall({ action: "delete" })); + assert.equal(invalidAction.isError, true); + assert.match(invalidAction.content[0].text, /action/); + + const missingTarget = await bundle.executeToolCall(createToolCall({ action: "create" })); + assert.equal(missingTarget.isError, true); + assert.match(missingTarget.content[0].text, /targetUrl/); + + const invalidTtl = await bundle.executeToolCall( + createToolCall({ action: "create", targetUrl: "http://localhost:3000", ttlSeconds: 60 }), + ); + assert.equal(invalidTtl.isError, true); + assert.match(invalidTtl.content[0].text, /ttlSeconds/); + + const missingCloseTarget = await bundle.executeToolCall(createToolCall({ action: "close" })); + assert.equal(missingCloseTarget.isError, true); + assert.match(missingCloseTarget.content[0].text, /id or TunnelManager.slug/); + + assert.deepEqual(invocations, []); +});