From 920ca2b65ffe7e28d66923095cb1edc48b006dc4 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:18:11 +0800 Subject: [PATCH 01/10] chore(toolchain): adopt buf codegen, golangci-lint gate, slog, and pinned versions - buf.yaml/buf.gen.yaml replace raw protoc; make proto-check adds lint+breaking gates - .golangci.yml baseline; CI gains lint step and wider proto drift gate - stdlib log -> log/slog; internal/observability adds logging setup and v1/v2 protocol usage counters (exposed later via /api/status) - mise.toml (tracked) pins go/buf/protoc/golangci-lint/node/pnpm; CI matches --- .github/workflows/ci.yml | 28 ++++++++---- Makefile | 23 +++++----- crates/agent-gateway/.golangci.yml | 35 +++++++++++++++ crates/agent-gateway/buf.gen.yaml | 15 +++++++ crates/agent-gateway/buf.yaml | 33 ++++++++++++++ crates/agent-gateway/cmd/gateway/main.go | 26 +++++++---- .../internal/observability/logging.go | 13 ++++++ .../internal/observability/protousage.go | 44 +++++++++++++++++++ mise.toml | 10 +++++ 9 files changed, 198 insertions(+), 29 deletions(-) create mode 100644 crates/agent-gateway/.golangci.yml create mode 100644 crates/agent-gateway/buf.gen.yaml create mode 100644 crates/agent-gateway/buf.yaml create mode 100644 crates/agent-gateway/internal/observability/logging.go create mode 100644 crates/agent-gateway/internal/observability/protousage.go create mode 100644 mise.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e2d649e9..655d6701e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + # buf breaking 需要完整历史以对比 origin/main 的 proto 契约。 + fetch-depth: 0 - uses: actions/setup-go@v5 with: @@ -31,14 +34,14 @@ jobs: - name: Install pnpm run: npm install -g pnpm@10.32.1 - - name: Install protobuf toolchain + - name: Install buf + uses: bufbuild/buf-action@v1 + with: + setup_only: true + version: 1.71.0 + + - name: Install protobuf Go plugins run: | - sudo apt-get update - sudo apt-get install -y unzip - curl -fsSL -o "$RUNNER_TEMP/protoc.zip" \ - https://github.com/protocolbuffers/protobuf/releases/download/v34.0/protoc-34.0-linux-x86_64.zip - unzip -q "$RUNNER_TEMP/protoc.zip" -d "$RUNNER_TEMP/protoc" - echo "$RUNNER_TEMP/protoc/bin" >> "$GITHUB_PATH" go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" @@ -49,10 +52,19 @@ jobs: pnpm install --frozen-lockfile pnpm build + - name: Proto lint and breaking-change check + run: make proto-check BUF_BREAKING_AGAINST='../../.git#branch=origin/main,subdir=crates/agent-gateway' + - name: Check generated protobuf run: | make proto - git diff --exit-code -- crates/agent-gateway/internal/proto/v1 + git diff --exit-code -- crates/agent-gateway/internal/proto + + - name: Lint gateway + uses: golangci/golangci-lint-action@v8 + with: + version: v2.12.2 + working-directory: crates/agent-gateway - name: Test gateway working-directory: crates/agent-gateway diff --git a/Makefile b/Makefile index 437130e4e..bd5e8fd14 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,6 @@ AGENT_GUI_DIR := crates/agent-gui AGENT_GATEWAY_DIR := crates/agent-gateway AGENT_GATEWAY_WEB_DIR := $(AGENT_GATEWAY_DIR)/web -AGENT_GATEWAY_PROTO_FILE := proto/v1/gateway.proto HOST_ARCH := $(shell uname -m) @@ -33,7 +32,7 @@ RELEASE_TAG ?= .PHONY: all dev build desktop-build-macos desktop-build-macos-release desktop-build-macos-intel desktop-build-macos-m desktop-build-windows desktop-build-linux github-release-main check-github-release-tag help .PHONY: dev-gateway dev-webui -.PHONY: proto webui gateway-build gateway-docker-build gateway-docker-run gateway-docker-smoke build-linux build-linux-amd build-linux-arm +.PHONY: proto proto-check webui gateway-build gateway-docker-build gateway-docker-run gateway-docker-smoke build-linux build-linux-amd build-linux-arm .PHONY: clean check-rust-target-% check-macos-signing-identity check-macos-notary-profile desktop-store-macos-notary-profile desktop-wait-macos-notary desktop-staple-macos desktop-verify-macos all: build gateway-build @@ -116,16 +115,16 @@ dev-webui: ## Gateway build and generated assets proto: - @command -v protoc >/dev/null || (echo "protoc is required" && exit 1) - @command -v protoc-gen-go >/dev/null || (echo "protoc-gen-go is required. Run: go install google.golang.org/protobuf/cmd/protoc-gen-go@latest" && exit 1) - @command -v protoc-gen-go-grpc >/dev/null || (echo "protoc-gen-go-grpc is required. Run: go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest" && exit 1) - protoc \ - --proto_path=$(AGENT_GATEWAY_DIR) \ - --go_out=$(AGENT_GATEWAY_DIR) \ - --go_opt=module=github.com/liveagent/agent-gateway \ - --go-grpc_out=$(AGENT_GATEWAY_DIR) \ - --go-grpc_opt=module=github.com/liveagent/agent-gateway \ - $(AGENT_GATEWAY_PROTO_FILE) + @command -v buf >/dev/null || (echo "buf is required. Run: mise install" && exit 1) + cd $(AGENT_GATEWAY_DIR) && buf generate + +# buf breaking 的对比基线(本地默认与当前 HEAD 对比;CI 覆写为 origin/main)。 +BUF_BREAKING_AGAINST ?= ../../.git#subdir=$(AGENT_GATEWAY_DIR) + +proto-check: + @command -v buf >/dev/null || (echo "buf is required. Run: mise install" && exit 1) + cd $(AGENT_GATEWAY_DIR) && buf lint + cd $(AGENT_GATEWAY_DIR) && buf breaking --against '$(BUF_BREAKING_AGAINST)' webui: pnpm --dir $(AGENT_GATEWAY_WEB_DIR) install --offline diff --git a/crates/agent-gateway/.golangci.yml b/crates/agent-gateway/.golangci.yml new file mode 100644 index 000000000..97bd44c7c --- /dev/null +++ b/crates/agent-gateway/.golangci.yml @@ -0,0 +1,35 @@ +# golangci-lint v2 —— 网关 Go 代码检查基线(此前无 linter,选务实检查集):govet/staticcheck/unused +# 管正确性与死代码(SA1019 兼作"调用已弃用 v1 符号"提示),errcheck/ineffassign/misspell 做基础卫生。 +# 生成代码(internal/proto/)不参与检查。 +version: "2" + +linters: + default: none + enable: + - govet + - staticcheck + - unused + - errcheck + - ineffassign + - misspell + exclusions: + generated: lax + rules: + # v1 协议实现、其组装点与测试引用弃用 v1 符号是预期行为(整层同生命周期弃用): + # 对这些路径静默 SA1019,避免刷屏。 + - path: internal/server/ + linters: [staticcheck] + text: "SA1019" + - path: cmd/gateway/ + linters: [staticcheck] + text: "SA1019" + - path: test/ + linters: [staticcheck] + text: "SA1019" + # 测试代码允许忽略清理类调用的返回值。 + - path: _test\.go + linters: [errcheck] + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/crates/agent-gateway/buf.gen.yaml b/crates/agent-gateway/buf.gen.yaml new file mode 100644 index 000000000..8955dd96f --- /dev/null +++ b/crates/agent-gateway/buf.gen.yaml @@ -0,0 +1,15 @@ +# buf 代码生成配置(v2 schema)。Go 插件用 mise 固定版本、`module=` 与历史 protoc 一致, +# 输出 internal/proto/;TS 插件 protoc-gen-es 由 web/node_modules 锁定,输出 web/src/lib/proto/gen/。 +version: v2 +plugins: + - local: protoc-gen-go + out: . + opt: module=github.com/liveagent/agent-gateway + - local: protoc-gen-go-grpc + out: . + opt: module=github.com/liveagent/agent-gateway + - local: web/node_modules/.bin/protoc-gen-es + out: web/src/lib/proto/gen + opt: target=ts +inputs: + - directory: . diff --git a/crates/agent-gateway/buf.yaml b/crates/agent-gateway/buf.yaml new file mode 100644 index 000000000..762fa006e --- /dev/null +++ b/crates/agent-gateway/buf.yaml @@ -0,0 +1,33 @@ +# buf 模块配置(v2 schema)。模块根设在 crates/agent-gateway/ 而非 proto/,保持 import 路径 +# "proto/v1/gateway.proto" 与历史 protoc 及 Rust build.rs 一致,Go 生成物与既有提交字节级一致。 +version: v2 +modules: + - path: . + excludes: + # 排除 WebUI node_modules 里的第三方 .proto。 + - web +lint: + use: + - STANDARD + except: + # 历史布局 proto/<版本>/ 早于 buf;迁目录会改 import 路径、破坏 Rust build.rs 与生成物,不翻新。 + - PACKAGE_DIRECTORY_MATCH + # v1 契约早于 buf lint,历史命名不翻新(wire 兼容优先);豁免为全量 lint 确认后的最小集, + # 仅限 v1 文件——v2 及新增 proto 必须完整满足 STANDARD。 + ignore_only: + SERVICE_SUFFIX: + - proto/v1/gateway.proto + RPC_REQUEST_STANDARD_NAME: + - proto/v1/gateway.proto + RPC_RESPONSE_STANDARD_NAME: + - proto/v1/gateway.proto + RPC_REQUEST_RESPONSE_UNIQUE: + - proto/v1/gateway.proto + ENUM_VALUE_PREFIX: + - proto/v1/gateway.proto + ENUM_ZERO_VALUE_SUFFIX: + - proto/v1/gateway.proto +breaking: + use: + # WIRE_JSON:兼守二进制与 JSON 线格式兼容(v1 JSON 协议仍在服务期)。 + - WIRE_JSON diff --git a/crates/agent-gateway/cmd/gateway/main.go b/crates/agent-gateway/cmd/gateway/main.go index 358236bf5..6444d332a 100644 --- a/crates/agent-gateway/cmd/gateway/main.go +++ b/crates/agent-gateway/cmd/gateway/main.go @@ -3,7 +3,7 @@ package main import ( "context" "errors" - "log" + "log/slog" "net" "net/http" "os" @@ -18,6 +18,7 @@ import ( "github.com/liveagent/agent-gateway/internal/auth" "github.com/liveagent/agent-gateway/internal/config" + "github.com/liveagent/agent-gateway/internal/observability" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" "github.com/liveagent/agent-gateway/internal/server" "github.com/liveagent/agent-gateway/internal/session" @@ -25,18 +26,25 @@ import ( const grpcShutdownTimeout = 3 * time.Second +// fatal 记录错误并以非零码退出(slog 没有 Fatal 级别,集中在此处理)。 +func fatal(msg string, args ...any) { + slog.Error(msg, args...) + os.Exit(1) +} + func main() { + observability.SetupLogging() cfg := config.Load() sm := session.NewManager() grpcServer, err := newGRPCServer(cfg, sm) if err != nil { - log.Fatalf("create gRPC server: %v", err) + fatal("create gRPC server failed", "err", err) } grpcListener, err := net.Listen("tcp", cfg.GRPCAddr) if err != nil { - log.Fatalf("listen gRPC: %v", err) + fatal("listen gRPC failed", "addr", cfg.GRPCAddr, "err", err) } httpServer := &http.Server{ @@ -48,14 +56,14 @@ func main() { errCh := make(chan error, 2) go func() { - log.Printf("gRPC listening on %s", cfg.GRPCAddr) + slog.Info("gRPC listening", "addr", cfg.GRPCAddr) if serveErr := grpcServer.Serve(grpcListener); serveErr != nil && !errors.Is(serveErr, grpc.ErrServerStopped) { errCh <- serveErr } }() go func() { - log.Printf("HTTP listening on %s", cfg.HTTPAddr) + slog.Info("HTTP listening", "addr", cfg.HTTPAddr) var serveErr error if cfg.TLSCert != "" || cfg.TLSKey != "" { serveErr = httpServer.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey) @@ -72,9 +80,9 @@ func main() { select { case sig := <-signalCh: - log.Printf("received signal %s, shutting down", sig) + slog.Info("received signal, shutting down", "signal", sig.String()) case err := <-errCh: - log.Fatalf("server error: %v", err) + fatal("server error", "err", err) } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -86,11 +94,11 @@ func main() { }() if forced := shutdownGRPCServer(grpcServer, grpcShutdownTimeout); forced { - log.Printf("gRPC graceful shutdown timed out after %s, forcing stop", grpcShutdownTimeout) + slog.Warn("gRPC graceful shutdown timed out, forcing stop", "timeout", grpcShutdownTimeout) } if err := <-httpShutdownErrCh; err != nil { - log.Printf("http shutdown error: %v", err) + slog.Warn("http shutdown error", "err", err) } } diff --git a/crates/agent-gateway/internal/observability/logging.go b/crates/agent-gateway/internal/observability/logging.go new file mode 100644 index 000000000..8fa722984 --- /dev/null +++ b/crates/agent-gateway/internal/observability/logging.go @@ -0,0 +1,13 @@ +// Package observability 汇集网关的可观测性基础设施(slog 进程级初始化、v1/v2 协议使用打点)。 +package observability + +import ( + "log/slog" + "os" +) + +// SetupLogging 安装进程级默认 slog logger:单行 key=value 输出到 stderr, +// 对容器/journald 日志采集友好,结构化字段便于检索与告警。 +func SetupLogging() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) +} diff --git a/crates/agent-gateway/internal/observability/protousage.go b/crates/agent-gateway/internal/observability/protousage.go new file mode 100644 index 000000000..2e7da55e2 --- /dev/null +++ b/crates/agent-gateway/internal/observability/protousage.go @@ -0,0 +1,44 @@ +package observability + +import "sync/atomic" + +// ProtoUsage 统计 v1(已弃用)与 v2 协议链路使用量:进程内原子计数,经 /api/status 的 +// protocol_usage 字段暴露;v1 计数停增且 active 归零即可安全删除 v1 代码。 +type ProtoUsage struct { + V1WSConnectionsTotal atomic.Int64 + V1WSConnectionsActive atomic.Int64 + V1WSRequestsTotal atomic.Int64 + V1TerminalWSConnectionsTotal atomic.Int64 + V1GRPCAgentConnectsTotal atomic.Int64 + V1GRPCAgentActive atomic.Int64 + V1GRPCTerminalConnectsTotal atomic.Int64 + + V2BrowserConnectionsTotal atomic.Int64 + V2BrowserConnectionsActive atomic.Int64 + V2BrowserRequestsTotal atomic.Int64 + V2AgentConnectsTotal atomic.Int64 + V2AgentActive atomic.Int64 + V2TerminalConnectsTotal atomic.Int64 +} + +// Usage 是进程级单例;各协议层直接打点。 +var Usage ProtoUsage + +// Snapshot 导出当前计数(键名即对外 JSON 字段名)。 +func (u *ProtoUsage) Snapshot() map[string]int64 { + return map[string]int64{ + "v1_ws_connections_total": u.V1WSConnectionsTotal.Load(), + "v1_ws_connections_active": u.V1WSConnectionsActive.Load(), + "v1_ws_requests_total": u.V1WSRequestsTotal.Load(), + "v1_terminal_ws_connections_total": u.V1TerminalWSConnectionsTotal.Load(), + "v1_grpc_agent_connects_total": u.V1GRPCAgentConnectsTotal.Load(), + "v1_grpc_agent_active": u.V1GRPCAgentActive.Load(), + "v1_grpc_terminal_connects_total": u.V1GRPCTerminalConnectsTotal.Load(), + "v2_browser_connections_total": u.V2BrowserConnectionsTotal.Load(), + "v2_browser_connections_active": u.V2BrowserConnectionsActive.Load(), + "v2_browser_requests_total": u.V2BrowserRequestsTotal.Load(), + "v2_agent_connects_total": u.V2AgentConnectsTotal.Load(), + "v2_agent_active": u.V2AgentActive.Load(), + "v2_terminal_connects_total": u.V2TerminalConnectsTotal.Load(), + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..a6b56bcd0 --- /dev/null +++ b/mise.toml @@ -0,0 +1,10 @@ +# 项目共享工具链版本。进入仓库目录执行 `mise install` 即可安装全部固定版本 +[tools] +go = "1.25.12" +node = "22.19.0" +"npm:pnpm" = "10.32.1" +protoc = "34.0" +"go:google.golang.org/protobuf/cmd/protoc-gen-go" = "1.36.11" +"go:google.golang.org/grpc/cmd/protoc-gen-go-grpc" = "1.6.2" +buf = "1.71.0" +golangci-lint = "2.12.2" From 7fa7adf60eb1c25db52fdedc1b3f1dec4fe39594 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:18:35 +0800 Subject: [PATCH 02/10] fix(gateway): resolve golangci-lint baseline findings unchecked defers, unused symbols, ineffectual assignment, staticcheck simplifications; no behavior change --- .../internal/chatwire/payloads.go | 5 +++-- .../agent-gateway/internal/handler/helpers.go | 7 ------ .../internal/handler/image_proxy.go | 2 +- .../internal/handler/outbound_http.go | 5 +---- .../agent-gateway/internal/handler/upload.go | 2 +- .../internal/server/http_test.go | 2 -- .../internal/server/tunnel_proxy.go | 2 +- .../internal/server/tunnel_rewrite.go | 6 ++--- .../internal/server/tunnel_rewrite_test.go | 1 - .../internal/session/conversation_stream.go | 5 +---- .../internal/session/manager_chat_queue.go | 4 +--- .../internal/session/manager_history_sync.go | 8 +++---- .../internal/session/manager_settings_sync.go | 8 +++---- .../internal/session/manager_sftp.go | 8 +++---- .../internal/session/manager_terminal.go | 8 +++---- .../test/tunnel/tunnel_e2e_test.go | 4 ++-- .../test/websocket/terminal_test.go | 22 ------------------- 17 files changed, 26 insertions(+), 73 deletions(-) diff --git a/crates/agent-gateway/internal/chatwire/payloads.go b/crates/agent-gateway/internal/chatwire/payloads.go index 567e51529..e1ed66b35 100644 --- a/crates/agent-gateway/internal/chatwire/payloads.go +++ b/crates/agent-gateway/internal/chatwire/payloads.go @@ -119,9 +119,10 @@ func countLines(s string) int { } n := 1 for i := 0; i < len(s); i++ { - if s[i] == '\n' { + switch s[i] { + case '\n': n++ - } else if s[i] == '\r' { + case '\r': n++ if i+1 < len(s) && s[i+1] == '\n' { i++ diff --git a/crates/agent-gateway/internal/handler/helpers.go b/crates/agent-gateway/internal/handler/helpers.go index 41edec9f4..a7ead5baa 100644 --- a/crates/agent-gateway/internal/handler/helpers.go +++ b/crates/agent-gateway/internal/handler/helpers.go @@ -12,13 +12,6 @@ import ( "github.com/liveagent/agent-gateway/internal/session" ) -func decodeJSON(r *http.Request, target any) error { - defer r.Body.Close() - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - return decoder.Decode(target) -} - func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/crates/agent-gateway/internal/handler/image_proxy.go b/crates/agent-gateway/internal/handler/image_proxy.go index 3df887aa6..f8982b45b 100644 --- a/crates/agent-gateway/internal/handler/image_proxy.go +++ b/crates/agent-gateway/internal/handler/image_proxy.go @@ -47,7 +47,7 @@ func imageProxyWithClient(client outboundHTTPClient) http.HandlerFunc { http.Error(w, fmt.Sprintf("failed to load image through proxy: %v", err), http.StatusBadGateway) return } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { http.Error(w, fmt.Sprintf("image proxy upstream returned HTTP status %d", resp.StatusCode), http.StatusBadGateway) diff --git a/crates/agent-gateway/internal/handler/outbound_http.go b/crates/agent-gateway/internal/handler/outbound_http.go index ec112dcc5..4dff957f6 100644 --- a/crates/agent-gateway/internal/handler/outbound_http.go +++ b/crates/agent-gateway/internal/handler/outbound_http.go @@ -173,8 +173,5 @@ func isSafeOutboundBlockedError(err error) bool { return true } var credentials *safeurl.SendingCredentialsBlockedError - if errors.As(err, &credentials) { - return true - } - return false + return errors.As(err, &credentials) } diff --git a/crates/agent-gateway/internal/handler/upload.go b/crates/agent-gateway/internal/handler/upload.go index ce538a995..c83516cd5 100644 --- a/crates/agent-gateway/internal/handler/upload.go +++ b/crates/agent-gateway/internal/handler/upload.go @@ -35,7 +35,7 @@ func ImportReadableFiles( return } if r.MultipartForm != nil { - defer r.MultipartForm.RemoveAll() + defer func() { _ = r.MultipartForm.RemoveAll() }() } workdir := strings.TrimSpace(r.FormValue("workdir")) diff --git a/crates/agent-gateway/internal/server/http_test.go b/crates/agent-gateway/internal/server/http_test.go index 3dddd44b3..b52e874e3 100644 --- a/crates/agent-gateway/internal/server/http_test.go +++ b/crates/agent-gateway/internal/server/http_test.go @@ -302,7 +302,6 @@ func TestPublicHistoryShareReturnsUnavailableWhenAgentOffline(t *testing.T) { } } - func TestOriginAllowedRequiresStrictOriginForPublicHosts(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://gateway.test:8080/api/chat/commands", nil) req.Header.Set("Origin", "http://gateway.test:5173") @@ -331,4 +330,3 @@ func TestOriginAllowedUsesForwardedProtoForSameOrigin(t *testing.T) { t.Fatal("expected forwarded https origin to be allowed") } } - diff --git a/crates/agent-gateway/internal/server/tunnel_proxy.go b/crates/agent-gateway/internal/server/tunnel_proxy.go index 380ae69c8..4f0252e93 100644 --- a/crates/agent-gateway/internal/server/tunnel_proxy.go +++ b/crates/agent-gateway/internal/server/tunnel_proxy.go @@ -455,7 +455,7 @@ func streamTunnelHTTPRequestBody( defer func() { result <- resultErr }() - defer body.Close() + defer func() { _ = body.Close() }() buffer := make([]byte, tunnelBodyChunkSize) sent := 0 diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite.go b/crates/agent-gateway/internal/server/tunnel_rewrite.go index 8a7dd8e2e..b30e4601c 100644 --- a/crates/agent-gateway/internal/server/tunnel_rewrite.go +++ b/crates/agent-gateway/internal/server/tunnel_rewrite.go @@ -53,10 +53,10 @@ func tunnelResponseRewriteKindFor( } mediaType = strings.ToLower(strings.TrimSpace(mediaType)) - switch { - case mediaType == "text/html" || mediaType == "application/xhtml+xml": + switch mediaType { + case "text/html", "application/xhtml+xml": return tunnelResponseRewriteHTML - case mediaType == "text/css": + case "text/css": return tunnelResponseRewriteCSS default: return tunnelResponseRewriteNone diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite_test.go b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go index 4594171c3..c318f43d7 100644 --- a/crates/agent-gateway/internal/server/tunnel_rewrite_test.go +++ b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go @@ -4,7 +4,6 @@ import ( "net/http" "strings" "testing" - ) func TestTunnelResponseRewriteKindFor(t *testing.T) { diff --git a/crates/agent-gateway/internal/session/conversation_stream.go b/crates/agent-gateway/internal/session/conversation_stream.go index 397542ce4..f7f6976dc 100644 --- a/crates/agent-gateway/internal/session/conversation_stream.go +++ b/crates/agent-gateway/internal/session/conversation_stream.go @@ -343,10 +343,7 @@ func (m *Manager) SubscribeConversationStream( stream := s.streamLocked(conversationID, now) s.evictStreamLocked(stream, now) - reset := false - if clientEpoch != "" && clientEpoch != stream.streamEpoch { - reset = true - } + reset := clientEpoch != "" && clientEpoch != stream.streamEpoch if afterSeq > stream.lastSeq { reset = true } diff --git a/crates/agent-gateway/internal/session/manager_chat_queue.go b/crates/agent-gateway/internal/session/manager_chat_queue.go index 40c86c846..f08cf8036 100644 --- a/crates/agent-gateway/internal/session/manager_chat_queue.go +++ b/crates/agent-gateway/internal/session/manager_chat_queue.go @@ -36,9 +36,7 @@ func (m *Manager) SubscribeChatQueueEvents() (<-chan *gatewayv1.ChatQueueEvent, cleanup := func() { m.syncHub.chatQueueMu.Lock() - if _, ok := m.syncHub.chatQueueSubscribers[subID]; ok { - delete(m.syncHub.chatQueueSubscribers, subID) - } + delete(m.syncHub.chatQueueSubscribers, subID) m.syncHub.chatQueueMu.Unlock() } diff --git a/crates/agent-gateway/internal/session/manager_history_sync.go b/crates/agent-gateway/internal/session/manager_history_sync.go index 9b67bfa38..9664ebc4e 100644 --- a/crates/agent-gateway/internal/session/manager_history_sync.go +++ b/crates/agent-gateway/internal/session/manager_history_sync.go @@ -15,11 +15,9 @@ func (m *Manager) SubscribeHistorySync() (<-chan *gatewayv1.HistorySyncEvent, fu cleanup := func() { m.syncHub.historyMu.Lock() - if _, ok := m.syncHub.historySubscribers[subID]; ok { - // Do not close the channel here: broadcastHistorySync sends after - // copying subscribers, so closing can race with an in-flight send. - delete(m.syncHub.historySubscribers, subID) - } + // Do not close the channel here: broadcastHistorySync sends after + // copying subscribers, so closing can race with an in-flight send. + delete(m.syncHub.historySubscribers, subID) m.syncHub.historyMu.Unlock() } diff --git a/crates/agent-gateway/internal/session/manager_settings_sync.go b/crates/agent-gateway/internal/session/manager_settings_sync.go index f5dabf444..b3284f111 100644 --- a/crates/agent-gateway/internal/session/manager_settings_sync.go +++ b/crates/agent-gateway/internal/session/manager_settings_sync.go @@ -18,11 +18,9 @@ func (m *Manager) SubscribeSettingsSync() (<-chan *gatewayv1.SettingsSyncEvent, cleanup := func() { m.syncHub.settingsMu.Lock() - if _, ok := m.syncHub.settingsSubscribers[subID]; ok { - // Do not close the channel here: broadcastSettingsSync sends after - // copying subscribers, so closing can race with an in-flight send. - delete(m.syncHub.settingsSubscribers, subID) - } + // Do not close the channel here: broadcastSettingsSync sends after + // copying subscribers, so closing can race with an in-flight send. + delete(m.syncHub.settingsSubscribers, subID) m.syncHub.settingsMu.Unlock() } diff --git a/crates/agent-gateway/internal/session/manager_sftp.go b/crates/agent-gateway/internal/session/manager_sftp.go index 8fad643b8..a237a1615 100644 --- a/crates/agent-gateway/internal/session/manager_sftp.go +++ b/crates/agent-gateway/internal/session/manager_sftp.go @@ -17,11 +17,9 @@ func (m *Manager) SubscribeSftpEvents() (<-chan *gatewayv1.SftpEvent, func()) { cleanup := func() { m.syncHub.sftpMu.Lock() - if _, ok := m.syncHub.sftpSubscribers[subID]; ok { - // Do not close the channel here: broadcastSftpEvent sends after - // copying subscribers, so closing can race with an in-flight send. - delete(m.syncHub.sftpSubscribers, subID) - } + // Do not close the channel here: broadcastSftpEvent sends after + // copying subscribers, so closing can race with an in-flight send. + delete(m.syncHub.sftpSubscribers, subID) m.syncHub.sftpMu.Unlock() } diff --git a/crates/agent-gateway/internal/session/manager_terminal.go b/crates/agent-gateway/internal/session/manager_terminal.go index 6ccd31db7..990c1ec5a 100644 --- a/crates/agent-gateway/internal/session/manager_terminal.go +++ b/crates/agent-gateway/internal/session/manager_terminal.go @@ -20,11 +20,9 @@ func (m *Manager) SubscribeTerminalEvents() (<-chan *gatewayv1.TerminalEvent, fu cleanup := func() { m.syncHub.terminalMu.Lock() - if _, ok := m.syncHub.terminalSubscribers[subID]; ok { - // Do not close the channel here: broadcastTerminalEvent sends after - // copying subscribers, so closing can race with an in-flight send. - delete(m.syncHub.terminalSubscribers, subID) - } + // Do not close the channel here: broadcastTerminalEvent sends after + // copying subscribers, so closing can race with an in-flight send. + delete(m.syncHub.terminalSubscribers, subID) m.syncHub.terminalMu.Unlock() } diff --git a/crates/agent-gateway/test/tunnel/tunnel_e2e_test.go b/crates/agent-gateway/test/tunnel/tunnel_e2e_test.go index 1cddc8ba7..7eb373467 100644 --- a/crates/agent-gateway/test/tunnel/tunnel_e2e_test.go +++ b/crates/agent-gateway/test/tunnel/tunnel_e2e_test.go @@ -51,7 +51,7 @@ func (a *fakeAgent) run() { continue } outbound.Ack(nil) - frame := outbound.GatewayEnvelope.GetTunnelFrame() + frame := outbound.GetTunnelFrame() if frame == nil { continue } @@ -320,7 +320,7 @@ func TestTunnelHTMLRewriteInjectsShimAndDropsContentLength(t *testing.T) { continue } outbound.Ack(nil) - frame := outbound.GatewayEnvelope.GetTunnelFrame() + frame := outbound.GetTunnelFrame() if frame == nil || frame.GetKind() != gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START { continue diff --git a/crates/agent-gateway/test/websocket/terminal_test.go b/crates/agent-gateway/test/websocket/terminal_test.go index ea49e8d64..0bf27ded1 100644 --- a/crates/agent-gateway/test/websocket/terminal_test.go +++ b/crates/agent-gateway/test/websocket/terminal_test.go @@ -37,28 +37,6 @@ func receiveNoTerminalEnvelope(t *testing.T, _ *websocket.Conn) { time.Sleep(150 * time.Millisecond) } -func assertNoTerminalEnvelopeAtEnd(t *testing.T, conn *websocket.Conn) { - t.Helper() - for { - if err := conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)); err != nil { - t.Fatalf("set websocket short deadline: %v", err) - } - var env wsEnvelope - err := conn.ReadJSON(&env) - if err == nil { - if env.Type == "tunnel.state" { - continue - } - t.Fatalf("unexpected websocket envelope = %#v", env) - } - var netErr net.Error - if !errors.As(err, &netErr) || !netErr.Timeout() { - t.Fatalf("receive websocket envelope returned %v, want timeout", err) - } - return - } -} - func assertTerminalEventKind(t *testing.T, env wsEnvelope, wantKind string) { t.Helper() if env.Type != "terminal.event" { From 3317cd960ac007024709e2160347312d37def457 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:18:57 +0800 Subject: [PATCH 03/10] feat(proto): add v2 WebSocket frame schema reusing v1 business messages - proto/v2/gateway_ws.proto: hello handshake, browser/agent/terminal frame shells, gateway-local payloads; v1 messages stay the three-end source of truth - deprecate service AgentGateway (option deprecated); CancelChatRequest gains run_id - Rust build.rs compiles both packages as gateway_proto::{v1,v2} with proto alias --- .../internal/proto/v1/gateway.pb.go | 24 +- .../internal/proto/v1/gateway_grpc.pb.go | 16 +- .../internal/proto/v2/gateway_ws.pb.go | 2973 +++++++++++++++++ crates/agent-gateway/proto/v1/gateway.proto | 7 + .../agent-gateway/proto/v2/gateway_ws.proto | 313 ++ crates/agent-gui/src-tauri/build.rs | 18 +- .../src-tauri/src/services/gateway/mod.rs | 23 +- 7 files changed, 3356 insertions(+), 18 deletions(-) create mode 100644 crates/agent-gateway/internal/proto/v2/gateway_ws.pb.go create mode 100644 crates/agent-gateway/proto/v2/gateway_ws.proto diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index 13429e8dc..6877e64f9 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v7.34.0 +// protoc (unknown) // source: proto/v1/gateway.proto package gatewayv1 @@ -5912,8 +5912,10 @@ func (x *ChatMessageRef) GetContentHash() string { 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 + // 可选运行 id 提示:v2 浏览器链路用它消除同会话并发运行的歧义;桌面端可忽略。 + RunId string `protobuf:"bytes,2,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CancelChatRequest) Reset() { @@ -5953,6 +5955,13 @@ func (x *CancelChatRequest) GetConversationId() string { return "" } +func (x *CancelChatRequest) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + type ChatCommandRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` @@ -11529,9 +11538,10 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\n" + "message_id\x18\x04 \x01(\tR\tmessageId\x12\x12\n" + "\x04role\x18\x05 \x01(\tR\x04role\x12!\n" + - "\fcontent_hash\x18\x06 \x01(\tR\vcontentHash\"<\n" + + "\fcontent_hash\x18\x06 \x01(\tR\vcontentHash\"S\n" + "\x11CancelChatRequest\x12'\n" + - "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\"\xf6\x01\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x15\n" + + "\x06run_id\x18\x02 \x01(\tR\x05runId\"\xf6\x01\n" + "\x12ChatCommandRequest\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12;\n" + "\arequest\x18\x02 \x01(\v2!.liveagent.gateway.v1.ChatRequestR\arequest\x12N\n" + @@ -11934,11 +11944,11 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\x13TunnelWsMessageType\x12&\n" + "\"TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED\x10\x00\x12\x1f\n" + "\x1bTUNNEL_WS_MESSAGE_TYPE_TEXT\x10\x01\x12!\n" + - "\x1dTUNNEL_WS_MESSAGE_TYPE_BINARY\x10\x022\xb7\x02\n" + + "\x1dTUNNEL_WS_MESSAGE_TYPE_BINARY\x10\x022\xbc\x02\n" + "\fAgentGateway\x12^\n" + "\fAgentConnect\x12#.liveagent.gateway.v1.AgentEnvelope\x1a%.liveagent.gateway.v1.GatewayEnvelope(\x010\x01\x12p\n" + "\x14AgentTerminalConnect\x12).liveagent.gateway.v1.TerminalStreamFrame\x1a).liveagent.gateway.v1.TerminalStreamFrame(\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" + "\fAuthenticate\x12!.liveagent.gateway.v1.AuthRequest\x1a\".liveagent.gateway.v1.AuthResponse\x1a\x03\x88\x02\x01B@Z>github.com/liveagent/agent-gateway/internal/proto/v1;gatewayv1b\x06proto3" var ( file_proto_v1_gateway_proto_rawDescOnce sync.Once diff --git a/crates/agent-gateway/internal/proto/v1/gateway_grpc.pb.go b/crates/agent-gateway/internal/proto/v1/gateway_grpc.pb.go index a61f0dfff..5a7e6d446 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway_grpc.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v7.34.0 +// - protoc (unknown) // source: proto/v1/gateway.proto package gatewayv1 @@ -27,6 +27,12 @@ const ( // AgentGatewayClient is the client API for AgentGateway service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// AgentGateway 是 v1 的桌面端 gRPC 服务。已弃用:v2 起由 /ws/v2/agent 与 /ws/v2/terminal +// (见 proto/v2/gateway_ws.proto)承担,仅为旧版桌面客户端保留,流量归零后删除; +// 本文件的消息是 v2 的业务载荷,全部保留、永不弃用。 +// +// Deprecated: Do not use. type AgentGatewayClient interface { AgentConnect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentEnvelope, GatewayEnvelope], error) AgentTerminalConnect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TerminalStreamFrame, TerminalStreamFrame], error) @@ -37,6 +43,7 @@ type agentGatewayClient struct { cc grpc.ClientConnInterface } +// Deprecated: Do not use. func NewAgentGatewayClient(cc grpc.ClientConnInterface) AgentGatewayClient { return &agentGatewayClient{cc} } @@ -80,6 +87,12 @@ func (c *agentGatewayClient) Authenticate(ctx context.Context, in *AuthRequest, // AgentGatewayServer is the server API for AgentGateway service. // All implementations must embed UnimplementedAgentGatewayServer // for forward compatibility. +// +// AgentGateway 是 v1 的桌面端 gRPC 服务。已弃用:v2 起由 /ws/v2/agent 与 /ws/v2/terminal +// (见 proto/v2/gateway_ws.proto)承担,仅为旧版桌面客户端保留,流量归零后删除; +// 本文件的消息是 v2 的业务载荷,全部保留、永不弃用。 +// +// Deprecated: Do not use. type AgentGatewayServer interface { AgentConnect(grpc.BidiStreamingServer[AgentEnvelope, GatewayEnvelope]) error AgentTerminalConnect(grpc.BidiStreamingServer[TerminalStreamFrame, TerminalStreamFrame]) error @@ -113,6 +126,7 @@ type UnsafeAgentGatewayServer interface { mustEmbedUnimplementedAgentGatewayServer() } +// Deprecated: Do not use. func RegisterAgentGatewayServer(s grpc.ServiceRegistrar, srv AgentGatewayServer) { // If the following call panics, it indicates UnimplementedAgentGatewayServer was // embedded by pointer and is nil. This will cause panics if an diff --git a/crates/agent-gateway/internal/proto/v2/gateway_ws.pb.go b/crates/agent-gateway/internal/proto/v2/gateway_ws.pb.go new file mode 100644 index 000000000..6180530eb --- /dev/null +++ b/crates/agent-gateway/internal/proto/v2/gateway_ws.pb.go @@ -0,0 +1,2973 @@ +// v2 统一线协议(WebSocket+Protobuf):/ws/v2(浏览器)、/ws/v2/agent(桌面端)、 +// /ws/v2/terminal(终端数据面)三链路的帧壳。一条 WS 二进制消息承载一条帧,文本帧即协议错误; +// 首帧必须为 hello,鉴权失败以 close code 4401 关闭。业务载荷复用 v1 消息(三端唯一事实源, +// 永不弃用、编号只增不改),本文件仅新增帧壳与未曾 proto 化的网关本地载荷。 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: proto/v2/gateway_ws.proto + +package gatewayv2 + +import ( + v1 "github.com/liveagent/agent-gateway/internal/proto/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ClientRole 区分 /ws/v2/terminal 上连接的所属端(该链路两端共用一条路径,靠 hello.role 区分)。 +type ClientRole int32 + +const ( + ClientRole_CLIENT_ROLE_UNSPECIFIED ClientRole = 0 + ClientRole_CLIENT_ROLE_BROWSER ClientRole = 1 + ClientRole_CLIENT_ROLE_AGENT ClientRole = 2 +) + +// Enum value maps for ClientRole. +var ( + ClientRole_name = map[int32]string{ + 0: "CLIENT_ROLE_UNSPECIFIED", + 1: "CLIENT_ROLE_BROWSER", + 2: "CLIENT_ROLE_AGENT", + } + ClientRole_value = map[string]int32{ + "CLIENT_ROLE_UNSPECIFIED": 0, + "CLIENT_ROLE_BROWSER": 1, + "CLIENT_ROLE_AGENT": 2, + } +) + +func (x ClientRole) Enum() *ClientRole { + p := new(ClientRole) + *p = x + return p +} + +func (x ClientRole) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ClientRole) Descriptor() protoreflect.EnumDescriptor { + return file_proto_v2_gateway_ws_proto_enumTypes[0].Descriptor() +} + +func (ClientRole) Type() protoreflect.EnumType { + return &file_proto_v2_gateway_ws_proto_enumTypes[0] +} + +func (x ClientRole) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ClientRole.Descriptor instead. +func (ClientRole) EnumDescriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{0} +} + +// ClientHello 是所有 v2 连接的第一帧。 +type ClientHello struct { + state protoimpl.MessageState `protogen:"open.v1"` + // 协议版本,当前恒为 2;未知版本被服务端拒绝。 + ProtocolVersion uint32 `protobuf:"varint,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + Role ClientRole `protobuf:"varint,2,opt,name=role,proto3,enum=liveagent.gateway.v2.ClientRole" json:"role,omitempty"` + // 网关访问令牌,服务端做常量时间比较。 + Token string `protobuf:"bytes,3,opt,name=token,proto3" json:"token,omitempty"` + // 以下字段仅 CLIENT_ROLE_AGENT 使用(对应 v1 gRPC AuthRequest)。 + AgentId string `protobuf:"bytes,4,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + AgentVersion string `protobuf:"bytes,5,opt,name=agent_version,json=agentVersion,proto3" json:"agent_version,omitempty"` + // 客户端标识(如 "webui" / "desktop"),仅用于观测与日志。 + ClientName string `protobuf:"bytes,6,opt,name=client_name,json=clientName,proto3" json:"client_name,omitempty"` + ClientVersion string `protobuf:"bytes,7,opt,name=client_version,json=clientVersion,proto3" json:"client_version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClientHello) Reset() { + *x = ClientHello{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClientHello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientHello) ProtoMessage() {} + +func (x *ClientHello) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[0] + 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 ClientHello.ProtoReflect.Descriptor instead. +func (*ClientHello) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{0} +} + +func (x *ClientHello) GetProtocolVersion() uint32 { + if x != nil { + return x.ProtocolVersion + } + return 0 +} + +func (x *ClientHello) GetRole() ClientRole { + if x != nil { + return x.Role + } + return ClientRole_CLIENT_ROLE_UNSPECIFIED +} + +func (x *ClientHello) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *ClientHello) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *ClientHello) GetAgentVersion() string { + if x != nil { + return x.AgentVersion + } + return "" +} + +func (x *ClientHello) GetClientName() string { + if x != nil { + return x.ClientName + } + return "" +} + +func (x *ClientHello) GetClientVersion() string { + if x != nil { + return x.ClientVersion + } + return "" +} + +// ServerHello 是服务端对 ClientHello 的应答;ok=false 时随即关闭连接。 +type ServerHello struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + // 仅 agent 角色返回(对应 v1 gRPC AuthResponse.session_id)。 + SessionId string `protobuf:"bytes,3,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + // 服务端 Unix 秒时间戳,供客户端校准。 + ServerTime int64 `protobuf:"varint,4,opt,name=server_time,json=serverTime,proto3" json:"server_time,omitempty"` + // 服务端心跳周期与消息大小上限,客户端应据此配置本地看门狗与分片。 + HeartbeatPeriodSeconds uint32 `protobuf:"varint,5,opt,name=heartbeat_period_seconds,json=heartbeatPeriodSeconds,proto3" json:"heartbeat_period_seconds,omitempty"` + MaxMessageBytes uint64 `protobuf:"varint,6,opt,name=max_message_bytes,json=maxMessageBytes,proto3" json:"max_message_bytes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServerHello) Reset() { + *x = ServerHello{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServerHello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServerHello) ProtoMessage() {} + +func (x *ServerHello) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[1] + 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 ServerHello.ProtoReflect.Descriptor instead. +func (*ServerHello) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{1} +} + +func (x *ServerHello) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *ServerHello) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *ServerHello) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ServerHello) GetServerTime() int64 { + if x != nil { + return x.ServerTime + } + return 0 +} + +func (x *ServerHello) GetHeartbeatPeriodSeconds() uint32 { + if x != nil { + return x.HeartbeatPeriodSeconds + } + return 0 +} + +func (x *ServerHello) GetMaxMessageBytes() uint64 { + if x != nil { + return x.MaxMessageBytes + } + return 0 +} + +// PingFrame / PongFrame 是应用层心跳:WS 控制帧 ping 探测网络栈,本帧探测页面 JS/事件循环存活。 +type PingFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + Timestamp int64 `protobuf:"varint,1,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingFrame) Reset() { + *x = PingFrame{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingFrame) ProtoMessage() {} + +func (x *PingFrame) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[2] + 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 PingFrame.ProtoReflect.Descriptor instead. +func (*PingFrame) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{2} +} + +func (x *PingFrame) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type PongFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + Timestamp int64 `protobuf:"varint,1,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PongFrame) Reset() { + *x = PongFrame{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PongFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PongFrame) ProtoMessage() {} + +func (x *PongFrame) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[3] + 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 PongFrame.ProtoReflect.Descriptor instead. +func (*PongFrame) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{3} +} + +func (x *PongFrame) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +// AckResult 是本地操作的通用确认应答(对应 v1 的 {"ok":true})。 +type AckResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AckResult) Reset() { + *x = AckResult{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AckResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AckResult) ProtoMessage() {} + +func (x *AckResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[4] + 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 AckResult.ProtoReflect.Descriptor instead. +func (*AckResult) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{4} +} + +func (x *AckResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +// WebClientFrame 为浏览器 → 网关方向的帧。除 agent_request 直通臂外,其余臂均为 +// 网关本地操作(由网关自身状态应答,不经桌面端往返)。 +type WebClientFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // 请求关联 id,客户端生成、响应帧回携;广播事件与心跳帧为空。 + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are valid to be assigned to Payload: + // + // *WebClientFrame_Hello + // *WebClientFrame_AgentRequest + // *WebClientFrame_StatusGet + // *WebClientFrame_ChatCommand + // *WebClientFrame_ChatPrepare + // *WebClientFrame_ChatSubscribe + // *WebClientFrame_ChatUnsubscribe + // *WebClientFrame_ChatActivities + // *WebClientFrame_WorkspaceSubscribe + // *WebClientFrame_WorkspaceUnsubscribe + // *WebClientFrame_Pong + Payload isWebClientFrame_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WebClientFrame) Reset() { + *x = WebClientFrame{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WebClientFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WebClientFrame) ProtoMessage() {} + +func (x *WebClientFrame) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[5] + 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 WebClientFrame.ProtoReflect.Descriptor instead. +func (*WebClientFrame) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{5} +} + +func (x *WebClientFrame) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *WebClientFrame) GetPayload() isWebClientFrame_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *WebClientFrame) GetHello() *ClientHello { + if x != nil { + if x, ok := x.Payload.(*WebClientFrame_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *WebClientFrame) GetAgentRequest() *v1.GatewayEnvelope { + if x != nil { + if x, ok := x.Payload.(*WebClientFrame_AgentRequest); ok { + return x.AgentRequest + } + } + return nil +} + +func (x *WebClientFrame) GetStatusGet() *StatusGetRequest { + if x != nil { + if x, ok := x.Payload.(*WebClientFrame_StatusGet); ok { + return x.StatusGet + } + } + return nil +} + +func (x *WebClientFrame) GetChatCommand() *v1.ChatCommandRequest { + if x != nil { + if x, ok := x.Payload.(*WebClientFrame_ChatCommand); ok { + return x.ChatCommand + } + } + return nil +} + +func (x *WebClientFrame) GetChatPrepare() *ChatPrepareRequest { + if x != nil { + if x, ok := x.Payload.(*WebClientFrame_ChatPrepare); ok { + return x.ChatPrepare + } + } + return nil +} + +func (x *WebClientFrame) GetChatSubscribe() *ChatSubscribeRequest { + if x != nil { + if x, ok := x.Payload.(*WebClientFrame_ChatSubscribe); ok { + return x.ChatSubscribe + } + } + return nil +} + +func (x *WebClientFrame) GetChatUnsubscribe() *ChatUnsubscribeRequest { + if x != nil { + if x, ok := x.Payload.(*WebClientFrame_ChatUnsubscribe); ok { + return x.ChatUnsubscribe + } + } + return nil +} + +func (x *WebClientFrame) GetChatActivities() *ChatActivitiesRequest { + if x != nil { + if x, ok := x.Payload.(*WebClientFrame_ChatActivities); ok { + return x.ChatActivities + } + } + return nil +} + +func (x *WebClientFrame) GetWorkspaceSubscribe() *WorkspaceSubscribeRequest { + if x != nil { + if x, ok := x.Payload.(*WebClientFrame_WorkspaceSubscribe); ok { + return x.WorkspaceSubscribe + } + } + return nil +} + +func (x *WebClientFrame) GetWorkspaceUnsubscribe() *WorkspaceUnsubscribeRequest { + if x != nil { + if x, ok := x.Payload.(*WebClientFrame_WorkspaceUnsubscribe); ok { + return x.WorkspaceUnsubscribe + } + } + return nil +} + +func (x *WebClientFrame) GetPong() *PongFrame { + if x != nil { + if x, ok := x.Payload.(*WebClientFrame_Pong); ok { + return x.Pong + } + } + return nil +} + +type isWebClientFrame_Payload interface { + isWebClientFrame_Payload() +} + +type WebClientFrame_Hello struct { + Hello *ClientHello `protobuf:"bytes,2,opt,name=hello,proto3,oneof"` +} + +type WebClientFrame_AgentRequest struct { + // 直通请求:网关校验白名单与限额后转发桌面端(request_id 按连接命名空间化防冲突)。 + AgentRequest *v1.GatewayEnvelope `protobuf:"bytes,3,opt,name=agent_request,json=agentRequest,proto3,oneof"` +} + +type WebClientFrame_StatusGet struct { + StatusGet *StatusGetRequest `protobuf:"bytes,4,opt,name=status_get,json=statusGet,proto3,oneof"` +} + +type WebClientFrame_ChatCommand struct { + ChatCommand *v1.ChatCommandRequest `protobuf:"bytes,5,opt,name=chat_command,json=chatCommand,proto3,oneof"` +} + +type WebClientFrame_ChatPrepare struct { + ChatPrepare *ChatPrepareRequest `protobuf:"bytes,6,opt,name=chat_prepare,json=chatPrepare,proto3,oneof"` +} + +type WebClientFrame_ChatSubscribe struct { + ChatSubscribe *ChatSubscribeRequest `protobuf:"bytes,7,opt,name=chat_subscribe,json=chatSubscribe,proto3,oneof"` +} + +type WebClientFrame_ChatUnsubscribe struct { + ChatUnsubscribe *ChatUnsubscribeRequest `protobuf:"bytes,8,opt,name=chat_unsubscribe,json=chatUnsubscribe,proto3,oneof"` +} + +type WebClientFrame_ChatActivities struct { + ChatActivities *ChatActivitiesRequest `protobuf:"bytes,9,opt,name=chat_activities,json=chatActivities,proto3,oneof"` +} + +type WebClientFrame_WorkspaceSubscribe struct { + WorkspaceSubscribe *WorkspaceSubscribeRequest `protobuf:"bytes,10,opt,name=workspace_subscribe,json=workspaceSubscribe,proto3,oneof"` +} + +type WebClientFrame_WorkspaceUnsubscribe struct { + WorkspaceUnsubscribe *WorkspaceUnsubscribeRequest `protobuf:"bytes,11,opt,name=workspace_unsubscribe,json=workspaceUnsubscribe,proto3,oneof"` +} + +type WebClientFrame_Pong struct { + Pong *PongFrame `protobuf:"bytes,12,opt,name=pong,proto3,oneof"` +} + +func (*WebClientFrame_Hello) isWebClientFrame_Payload() {} + +func (*WebClientFrame_AgentRequest) isWebClientFrame_Payload() {} + +func (*WebClientFrame_StatusGet) isWebClientFrame_Payload() {} + +func (*WebClientFrame_ChatCommand) isWebClientFrame_Payload() {} + +func (*WebClientFrame_ChatPrepare) isWebClientFrame_Payload() {} + +func (*WebClientFrame_ChatSubscribe) isWebClientFrame_Payload() {} + +func (*WebClientFrame_ChatUnsubscribe) isWebClientFrame_Payload() {} + +func (*WebClientFrame_ChatActivities) isWebClientFrame_Payload() {} + +func (*WebClientFrame_WorkspaceSubscribe) isWebClientFrame_Payload() {} + +func (*WebClientFrame_WorkspaceUnsubscribe) isWebClientFrame_Payload() {} + +func (*WebClientFrame_Pong) isWebClientFrame_Payload() {} + +// WebServerFrame 为网关 → 浏览器方向的帧。 +type WebServerFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // 关联响应回填请求 id;服务端主动推送(广播/心跳)时为空。 + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + // Types that are valid to be assigned to Payload: + // + // *WebServerFrame_Hello + // *WebServerFrame_AgentResponse + // *WebServerFrame_LocalError + // *WebServerFrame_Ping + // *WebServerFrame_Status + // *WebServerFrame_ChatSubscribed + // *WebServerFrame_ChatAccepted + // *WebServerFrame_ChatActivities + // *WebServerFrame_ChatEvent + // *WebServerFrame_ChatCommandUpdate + // *WebServerFrame_ChatSubscriptionReset + // *WebServerFrame_ChatActivity + // *WebServerFrame_Ack + // *WebServerFrame_ChatCancelled + // *WebServerFrame_HistoryEvent + // *WebServerFrame_SettingsEvent + // *WebServerFrame_TerminalEvent + // *WebServerFrame_SftpEvent + // *WebServerFrame_ChatQueueEvent + // *WebServerFrame_TunnelState + // *WebServerFrame_ProcessState + // *WebServerFrame_WorkspaceActivity + Payload isWebServerFrame_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WebServerFrame) Reset() { + *x = WebServerFrame{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WebServerFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WebServerFrame) ProtoMessage() {} + +func (x *WebServerFrame) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[6] + 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 WebServerFrame.ProtoReflect.Descriptor instead. +func (*WebServerFrame) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{6} +} + +func (x *WebServerFrame) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *WebServerFrame) GetPayload() isWebServerFrame_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *WebServerFrame) GetHello() *ServerHello { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *WebServerFrame) GetAgentResponse() *v1.AgentEnvelope { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_AgentResponse); ok { + return x.AgentResponse + } + } + return nil +} + +func (x *WebServerFrame) GetLocalError() *v1.ErrorResponse { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_LocalError); ok { + return x.LocalError + } + } + return nil +} + +func (x *WebServerFrame) GetPing() *PingFrame { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_Ping); ok { + return x.Ping + } + } + return nil +} + +func (x *WebServerFrame) GetStatus() *StatusEvent { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_Status); ok { + return x.Status + } + } + return nil +} + +func (x *WebServerFrame) GetChatSubscribed() *ChatSubscribeResult { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_ChatSubscribed); ok { + return x.ChatSubscribed + } + } + return nil +} + +func (x *WebServerFrame) GetChatAccepted() *ChatCommandAccepted { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_ChatAccepted); ok { + return x.ChatAccepted + } + } + return nil +} + +func (x *WebServerFrame) GetChatActivities() *ChatActivitiesResult { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_ChatActivities); ok { + return x.ChatActivities + } + } + return nil +} + +func (x *WebServerFrame) GetChatEvent() *ChatStreamEvent { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_ChatEvent); ok { + return x.ChatEvent + } + } + return nil +} + +func (x *WebServerFrame) GetChatCommandUpdate() *ChatCommandUpdate { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_ChatCommandUpdate); ok { + return x.ChatCommandUpdate + } + } + return nil +} + +func (x *WebServerFrame) GetChatSubscriptionReset() *ChatSubscriptionReset { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_ChatSubscriptionReset); ok { + return x.ChatSubscriptionReset + } + } + return nil +} + +func (x *WebServerFrame) GetChatActivity() *ChatActivityEvent { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_ChatActivity); ok { + return x.ChatActivity + } + } + return nil +} + +func (x *WebServerFrame) GetAck() *AckResult { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_Ack); ok { + return x.Ack + } + } + return nil +} + +func (x *WebServerFrame) GetChatCancelled() *ChatCancelResult { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_ChatCancelled); ok { + return x.ChatCancelled + } + } + return nil +} + +func (x *WebServerFrame) GetHistoryEvent() *v1.HistorySyncEvent { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_HistoryEvent); ok { + return x.HistoryEvent + } + } + return nil +} + +func (x *WebServerFrame) GetSettingsEvent() *v1.SettingsSyncEvent { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_SettingsEvent); ok { + return x.SettingsEvent + } + } + return nil +} + +func (x *WebServerFrame) GetTerminalEvent() *v1.TerminalEvent { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_TerminalEvent); ok { + return x.TerminalEvent + } + } + return nil +} + +func (x *WebServerFrame) GetSftpEvent() *v1.SftpEvent { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_SftpEvent); ok { + return x.SftpEvent + } + } + return nil +} + +func (x *WebServerFrame) GetChatQueueEvent() *v1.ChatQueueEvent { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_ChatQueueEvent); ok { + return x.ChatQueueEvent + } + } + return nil +} + +func (x *WebServerFrame) GetTunnelState() *v1.TunnelStateSnapshot { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_TunnelState); ok { + return x.TunnelState + } + } + return nil +} + +func (x *WebServerFrame) GetProcessState() *v1.ManagedProcessSnapshot { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_ProcessState); ok { + return x.ProcessState + } + } + return nil +} + +func (x *WebServerFrame) GetWorkspaceActivity() *v1.WorkspaceActivityEvent { + if x != nil { + if x, ok := x.Payload.(*WebServerFrame_WorkspaceActivity); ok { + return x.WorkspaceActivity + } + } + return nil +} + +type isWebServerFrame_Payload interface { + isWebServerFrame_Payload() +} + +type WebServerFrame_Hello struct { + Hello *ServerHello `protobuf:"bytes,2,opt,name=hello,proto3,oneof"` +} + +type WebServerFrame_AgentResponse struct { + // 直通响应:桌面端返回的 v1 信封(含 error=99 错误臂)。 + AgentResponse *v1.AgentEnvelope `protobuf:"bytes,3,opt,name=agent_response,json=agentResponse,proto3,oneof"` +} + +type WebServerFrame_LocalError struct { + // 网关本地错误(鉴权、校验、离线等;对应 v1 的 type:"error" 信封)。 + LocalError *v1.ErrorResponse `protobuf:"bytes,4,opt,name=local_error,json=localError,proto3,oneof"` +} + +type WebServerFrame_Ping struct { + Ping *PingFrame `protobuf:"bytes,5,opt,name=ping,proto3,oneof"` +} + +type WebServerFrame_Status struct { + // status_get / chat_prepare 的响应与 status 广播共用,新旧客户端共享一个状态归一化器。 + Status *StatusEvent `protobuf:"bytes,6,opt,name=status,proto3,oneof"` +} + +type WebServerFrame_ChatSubscribed struct { + ChatSubscribed *ChatSubscribeResult `protobuf:"bytes,7,opt,name=chat_subscribed,json=chatSubscribed,proto3,oneof"` +} + +type WebServerFrame_ChatAccepted struct { + ChatAccepted *ChatCommandAccepted `protobuf:"bytes,8,opt,name=chat_accepted,json=chatAccepted,proto3,oneof"` +} + +type WebServerFrame_ChatActivities struct { + ChatActivities *ChatActivitiesResult `protobuf:"bytes,9,opt,name=chat_activities,json=chatActivities,proto3,oneof"` +} + +type WebServerFrame_ChatEvent struct { + ChatEvent *ChatStreamEvent `protobuf:"bytes,10,opt,name=chat_event,json=chatEvent,proto3,oneof"` +} + +type WebServerFrame_ChatCommandUpdate struct { + ChatCommandUpdate *ChatCommandUpdate `protobuf:"bytes,11,opt,name=chat_command_update,json=chatCommandUpdate,proto3,oneof"` +} + +type WebServerFrame_ChatSubscriptionReset struct { + ChatSubscriptionReset *ChatSubscriptionReset `protobuf:"bytes,12,opt,name=chat_subscription_reset,json=chatSubscriptionReset,proto3,oneof"` +} + +type WebServerFrame_ChatActivity struct { + ChatActivity *ChatActivityEvent `protobuf:"bytes,13,opt,name=chat_activity,json=chatActivity,proto3,oneof"` +} + +type WebServerFrame_Ack struct { + Ack *AckResult `protobuf:"bytes,14,opt,name=ack,proto3,oneof"` +} + +type WebServerFrame_ChatCancelled struct { + ChatCancelled *ChatCancelResult `protobuf:"bytes,15,opt,name=chat_cancelled,json=chatCancelled,proto3,oneof"` +} + +type WebServerFrame_HistoryEvent struct { + // 广播事件:session 层吐出的 v1 seam 消息零塑形直转。 + HistoryEvent *v1.HistorySyncEvent `protobuf:"bytes,20,opt,name=history_event,json=historyEvent,proto3,oneof"` +} + +type WebServerFrame_SettingsEvent struct { + SettingsEvent *v1.SettingsSyncEvent `protobuf:"bytes,21,opt,name=settings_event,json=settingsEvent,proto3,oneof"` +} + +type WebServerFrame_TerminalEvent struct { + TerminalEvent *v1.TerminalEvent `protobuf:"bytes,22,opt,name=terminal_event,json=terminalEvent,proto3,oneof"` +} + +type WebServerFrame_SftpEvent struct { + SftpEvent *v1.SftpEvent `protobuf:"bytes,23,opt,name=sftp_event,json=sftpEvent,proto3,oneof"` +} + +type WebServerFrame_ChatQueueEvent struct { + ChatQueueEvent *v1.ChatQueueEvent `protobuf:"bytes,24,opt,name=chat_queue_event,json=chatQueueEvent,proto3,oneof"` +} + +type WebServerFrame_TunnelState struct { + TunnelState *v1.TunnelStateSnapshot `protobuf:"bytes,25,opt,name=tunnel_state,json=tunnelState,proto3,oneof"` +} + +type WebServerFrame_ProcessState struct { + ProcessState *v1.ManagedProcessSnapshot `protobuf:"bytes,26,opt,name=process_state,json=processState,proto3,oneof"` +} + +type WebServerFrame_WorkspaceActivity struct { + WorkspaceActivity *v1.WorkspaceActivityEvent `protobuf:"bytes,27,opt,name=workspace_activity,json=workspaceActivity,proto3,oneof"` +} + +func (*WebServerFrame_Hello) isWebServerFrame_Payload() {} + +func (*WebServerFrame_AgentResponse) isWebServerFrame_Payload() {} + +func (*WebServerFrame_LocalError) isWebServerFrame_Payload() {} + +func (*WebServerFrame_Ping) isWebServerFrame_Payload() {} + +func (*WebServerFrame_Status) isWebServerFrame_Payload() {} + +func (*WebServerFrame_ChatSubscribed) isWebServerFrame_Payload() {} + +func (*WebServerFrame_ChatAccepted) isWebServerFrame_Payload() {} + +func (*WebServerFrame_ChatActivities) isWebServerFrame_Payload() {} + +func (*WebServerFrame_ChatEvent) isWebServerFrame_Payload() {} + +func (*WebServerFrame_ChatCommandUpdate) isWebServerFrame_Payload() {} + +func (*WebServerFrame_ChatSubscriptionReset) isWebServerFrame_Payload() {} + +func (*WebServerFrame_ChatActivity) isWebServerFrame_Payload() {} + +func (*WebServerFrame_Ack) isWebServerFrame_Payload() {} + +func (*WebServerFrame_ChatCancelled) isWebServerFrame_Payload() {} + +func (*WebServerFrame_HistoryEvent) isWebServerFrame_Payload() {} + +func (*WebServerFrame_SettingsEvent) isWebServerFrame_Payload() {} + +func (*WebServerFrame_TerminalEvent) isWebServerFrame_Payload() {} + +func (*WebServerFrame_SftpEvent) isWebServerFrame_Payload() {} + +func (*WebServerFrame_ChatQueueEvent) isWebServerFrame_Payload() {} + +func (*WebServerFrame_TunnelState) isWebServerFrame_Payload() {} + +func (*WebServerFrame_ProcessState) isWebServerFrame_Payload() {} + +func (*WebServerFrame_WorkspaceActivity) isWebServerFrame_Payload() {} + +// AgentClientFrame 为桌面端 → 网关方向的帧。 +type AgentClientFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *AgentClientFrame_Hello + // *AgentClientFrame_Envelope + Payload isAgentClientFrame_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentClientFrame) Reset() { + *x = AgentClientFrame{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentClientFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentClientFrame) ProtoMessage() {} + +func (x *AgentClientFrame) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[7] + 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 AgentClientFrame.ProtoReflect.Descriptor instead. +func (*AgentClientFrame) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{7} +} + +func (x *AgentClientFrame) GetPayload() isAgentClientFrame_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *AgentClientFrame) GetHello() *ClientHello { + if x != nil { + if x, ok := x.Payload.(*AgentClientFrame_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *AgentClientFrame) GetEnvelope() *v1.AgentEnvelope { + if x != nil { + if x, ok := x.Payload.(*AgentClientFrame_Envelope); ok { + return x.Envelope + } + } + return nil +} + +type isAgentClientFrame_Payload interface { + isAgentClientFrame_Payload() +} + +type AgentClientFrame_Hello struct { + Hello *ClientHello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` +} + +type AgentClientFrame_Envelope struct { + Envelope *v1.AgentEnvelope `protobuf:"bytes,2,opt,name=envelope,proto3,oneof"` +} + +func (*AgentClientFrame_Hello) isAgentClientFrame_Payload() {} + +func (*AgentClientFrame_Envelope) isAgentClientFrame_Payload() {} + +// AgentServerFrame 为网关 → 桌面端方向的帧。 +type AgentServerFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *AgentServerFrame_Hello + // *AgentServerFrame_Envelope + Payload isAgentServerFrame_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentServerFrame) Reset() { + *x = AgentServerFrame{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentServerFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentServerFrame) ProtoMessage() {} + +func (x *AgentServerFrame) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[8] + 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 AgentServerFrame.ProtoReflect.Descriptor instead. +func (*AgentServerFrame) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{8} +} + +func (x *AgentServerFrame) GetPayload() isAgentServerFrame_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *AgentServerFrame) GetHello() *ServerHello { + if x != nil { + if x, ok := x.Payload.(*AgentServerFrame_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *AgentServerFrame) GetEnvelope() *v1.GatewayEnvelope { + if x != nil { + if x, ok := x.Payload.(*AgentServerFrame_Envelope); ok { + return x.Envelope + } + } + return nil +} + +type isAgentServerFrame_Payload interface { + isAgentServerFrame_Payload() +} + +type AgentServerFrame_Hello struct { + Hello *ServerHello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` +} + +type AgentServerFrame_Envelope struct { + Envelope *v1.GatewayEnvelope `protobuf:"bytes,2,opt,name=envelope,proto3,oneof"` +} + +func (*AgentServerFrame_Hello) isAgentServerFrame_Payload() {} + +func (*AgentServerFrame_Envelope) isAgentServerFrame_Payload() {} + +// TerminalClientFrame 为客户端(浏览器或桌面端)→ 网关方向的帧。 +type TerminalClientFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *TerminalClientFrame_Hello + // *TerminalClientFrame_Frame + Payload isTerminalClientFrame_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TerminalClientFrame) Reset() { + *x = TerminalClientFrame{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TerminalClientFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TerminalClientFrame) ProtoMessage() {} + +func (x *TerminalClientFrame) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[9] + 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 TerminalClientFrame.ProtoReflect.Descriptor instead. +func (*TerminalClientFrame) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{9} +} + +func (x *TerminalClientFrame) GetPayload() isTerminalClientFrame_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *TerminalClientFrame) GetHello() *ClientHello { + if x != nil { + if x, ok := x.Payload.(*TerminalClientFrame_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *TerminalClientFrame) GetFrame() *v1.TerminalStreamFrame { + if x != nil { + if x, ok := x.Payload.(*TerminalClientFrame_Frame); ok { + return x.Frame + } + } + return nil +} + +type isTerminalClientFrame_Payload interface { + isTerminalClientFrame_Payload() +} + +type TerminalClientFrame_Hello struct { + Hello *ClientHello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` +} + +type TerminalClientFrame_Frame struct { + Frame *v1.TerminalStreamFrame `protobuf:"bytes,2,opt,name=frame,proto3,oneof"` +} + +func (*TerminalClientFrame_Hello) isTerminalClientFrame_Payload() {} + +func (*TerminalClientFrame_Frame) isTerminalClientFrame_Payload() {} + +// TerminalServerFrame 为网关 → 客户端方向的帧。 +type TerminalServerFrame struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Payload: + // + // *TerminalServerFrame_Hello + // *TerminalServerFrame_Frame + Payload isTerminalServerFrame_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TerminalServerFrame) Reset() { + *x = TerminalServerFrame{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TerminalServerFrame) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TerminalServerFrame) ProtoMessage() {} + +func (x *TerminalServerFrame) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[10] + 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 TerminalServerFrame.ProtoReflect.Descriptor instead. +func (*TerminalServerFrame) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{10} +} + +func (x *TerminalServerFrame) GetPayload() isTerminalServerFrame_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *TerminalServerFrame) GetHello() *ServerHello { + if x != nil { + if x, ok := x.Payload.(*TerminalServerFrame_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *TerminalServerFrame) GetFrame() *v1.TerminalStreamFrame { + if x != nil { + if x, ok := x.Payload.(*TerminalServerFrame_Frame); ok { + return x.Frame + } + } + return nil +} + +type isTerminalServerFrame_Payload interface { + isTerminalServerFrame_Payload() +} + +type TerminalServerFrame_Hello struct { + Hello *ServerHello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` +} + +type TerminalServerFrame_Frame struct { + Frame *v1.TerminalStreamFrame `protobuf:"bytes,2,opt,name=frame,proto3,oneof"` +} + +func (*TerminalServerFrame_Hello) isTerminalServerFrame_Payload() {} + +func (*TerminalServerFrame_Frame) isTerminalServerFrame_Payload() {} + +// StatusGetRequest 请求网关侧运行状态快照(对应 v1 "status.get")。 +type StatusGetRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusGetRequest) Reset() { + *x = StatusGetRequest{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusGetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusGetRequest) ProtoMessage() {} + +func (x *StatusGetRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[11] + 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 StatusGetRequest.ProtoReflect.Descriptor instead. +func (*StatusGetRequest) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{11} +} + +// StatusEvent 镜像 session.Status 的 JSON 形状(字段一一对应)。 +type StatusEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Online bool `protobuf:"varint,1,opt,name=online,proto3" json:"online,omitempty"` + AgentReady bool `protobuf:"varint,2,opt,name=agent_ready,json=agentReady,proto3" json:"agent_ready,omitempty"` + ChatRuntimeReady bool `protobuf:"varint,3,opt,name=chat_runtime_ready,json=chatRuntimeReady,proto3" json:"chat_runtime_ready,omitempty"` + AgentId string `protobuf:"bytes,4,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + AgentVersion string `protobuf:"bytes,5,opt,name=agent_version,json=agentVersion,proto3" json:"agent_version,omitempty"` + SessionId string `protobuf:"bytes,6,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + ConnectedSince int64 `protobuf:"varint,7,opt,name=connected_since,json=connectedSince,proto3" json:"connected_since,omitempty"` + LastHeartbeat int64 `protobuf:"varint,8,opt,name=last_heartbeat,json=lastHeartbeat,proto3" json:"last_heartbeat,omitempty"` + RuntimeState string `protobuf:"bytes,9,opt,name=runtime_state,json=runtimeState,proto3" json:"runtime_state,omitempty"` + RuntimeLastHeartbeat int64 `protobuf:"varint,10,opt,name=runtime_last_heartbeat,json=runtimeLastHeartbeat,proto3" json:"runtime_last_heartbeat,omitempty"` + RuntimeWorkerId string `protobuf:"bytes,11,opt,name=runtime_worker_id,json=runtimeWorkerId,proto3" json:"runtime_worker_id,omitempty"` + RuntimeVisible bool `protobuf:"varint,12,opt,name=runtime_visible,json=runtimeVisible,proto3" json:"runtime_visible,omitempty"` + RuntimeActiveRunCount uint32 `protobuf:"varint,13,opt,name=runtime_active_run_count,json=runtimeActiveRunCount,proto3" json:"runtime_active_run_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusEvent) Reset() { + *x = StatusEvent{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusEvent) ProtoMessage() {} + +func (x *StatusEvent) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[12] + 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 StatusEvent.ProtoReflect.Descriptor instead. +func (*StatusEvent) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{12} +} + +func (x *StatusEvent) GetOnline() bool { + if x != nil { + return x.Online + } + return false +} + +func (x *StatusEvent) GetAgentReady() bool { + if x != nil { + return x.AgentReady + } + return false +} + +func (x *StatusEvent) GetChatRuntimeReady() bool { + if x != nil { + return x.ChatRuntimeReady + } + return false +} + +func (x *StatusEvent) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *StatusEvent) GetAgentVersion() string { + if x != nil { + return x.AgentVersion + } + return "" +} + +func (x *StatusEvent) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *StatusEvent) GetConnectedSince() int64 { + if x != nil { + return x.ConnectedSince + } + return 0 +} + +func (x *StatusEvent) GetLastHeartbeat() int64 { + if x != nil { + return x.LastHeartbeat + } + return 0 +} + +func (x *StatusEvent) GetRuntimeState() string { + if x != nil { + return x.RuntimeState + } + return "" +} + +func (x *StatusEvent) GetRuntimeLastHeartbeat() int64 { + if x != nil { + return x.RuntimeLastHeartbeat + } + return 0 +} + +func (x *StatusEvent) GetRuntimeWorkerId() string { + if x != nil { + return x.RuntimeWorkerId + } + return "" +} + +func (x *StatusEvent) GetRuntimeVisible() bool { + if x != nil { + return x.RuntimeVisible + } + return false +} + +func (x *StatusEvent) GetRuntimeActiveRunCount() uint32 { + if x != nil { + return x.RuntimeActiveRunCount + } + return 0 +} + +// ChatPrepareRequest 唤醒/探活桌面端 chat 运行时(对应 v1 "chat.prepare");响应为 StatusEvent。 +type ChatPrepareRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reason string `protobuf:"bytes,1,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatPrepareRequest) Reset() { + *x = ChatPrepareRequest{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatPrepareRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatPrepareRequest) ProtoMessage() {} + +func (x *ChatPrepareRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[13] + 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 ChatPrepareRequest.ProtoReflect.Descriptor instead. +func (*ChatPrepareRequest) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{13} +} + +func (x *ChatPrepareRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +// ChatSubscribeRequest 订阅会话事件流(对应 v1 "chat.subscribe")。after_seq + stream_epoch +// 支持断线重放:epoch 不匹配或序号超界时服务端置 reset 并从头回放。 +type ChatSubscribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + AfterSeq int64 `protobuf:"varint,2,opt,name=after_seq,json=afterSeq,proto3" json:"after_seq,omitempty"` + StreamEpoch string `protobuf:"bytes,3,opt,name=stream_epoch,json=streamEpoch,proto3" json:"stream_epoch,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatSubscribeRequest) Reset() { + *x = ChatSubscribeRequest{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatSubscribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatSubscribeRequest) ProtoMessage() {} + +func (x *ChatSubscribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[14] + 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 ChatSubscribeRequest.ProtoReflect.Descriptor instead. +func (*ChatSubscribeRequest) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{14} +} + +func (x *ChatSubscribeRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatSubscribeRequest) GetAfterSeq() int64 { + if x != nil { + return x.AfterSeq + } + return 0 +} + +func (x *ChatSubscribeRequest) GetStreamEpoch() string { + if x != nil { + return x.StreamEpoch + } + return "" +} + +// ChatRunActivity 镜像 session.RunActivity 的 JSON 形状。 +type ChatRunActivity struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"` + StartedSeq int64 `protobuf:"varint,3,opt,name=started_seq,json=startedSeq,proto3" json:"started_seq,omitempty"` + // Unix 毫秒时间戳。 + UpdatedAtMs int64 `protobuf:"varint,4,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + ToolStatus string `protobuf:"bytes,5,opt,name=tool_status,json=toolStatus,proto3" json:"tool_status,omitempty"` + ToolStatusIsCompaction bool `protobuf:"varint,6,opt,name=tool_status_is_compaction,json=toolStatusIsCompaction,proto3" json:"tool_status_is_compaction,omitempty"` + ClientRequestId string `protobuf:"bytes,7,opt,name=client_request_id,json=clientRequestId,proto3" json:"client_request_id,omitempty"` + // 以下两个字段仅在 chat_activities 列表中填充。 + ConversationId string `protobuf:"bytes,8,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + Workdir string `protobuf:"bytes,9,opt,name=workdir,proto3" json:"workdir,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatRunActivity) Reset() { + *x = ChatRunActivity{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatRunActivity) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatRunActivity) ProtoMessage() {} + +func (x *ChatRunActivity) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_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 ChatRunActivity.ProtoReflect.Descriptor instead. +func (*ChatRunActivity) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{15} +} + +func (x *ChatRunActivity) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ChatRunActivity) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *ChatRunActivity) GetStartedSeq() int64 { + if x != nil { + return x.StartedSeq + } + return 0 +} + +func (x *ChatRunActivity) GetUpdatedAtMs() int64 { + if x != nil { + return x.UpdatedAtMs + } + return 0 +} + +func (x *ChatRunActivity) GetToolStatus() string { + if x != nil { + return x.ToolStatus + } + return "" +} + +func (x *ChatRunActivity) GetToolStatusIsCompaction() bool { + if x != nil { + return x.ToolStatusIsCompaction + } + return false +} + +func (x *ChatRunActivity) GetClientRequestId() string { + if x != nil { + return x.ClientRequestId + } + return "" +} + +func (x *ChatRunActivity) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatRunActivity) GetWorkdir() string { + if x != nil { + return x.Workdir + } + return "" +} + +// ChatRunSnapshot 镜像 session.RunSnapshot 的 JSON 形状。 +type ChatRunSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + Revision int64 `protobuf:"varint,2,opt,name=revision,proto3" json:"revision,omitempty"` + // 桌面端渲染快照,内容为动态 JSON(与 v1 一致按字符串携带,不建模)。 + EntriesJson string `protobuf:"bytes,3,opt,name=entries_json,json=entriesJson,proto3" json:"entries_json,omitempty"` + ToolStatus string `protobuf:"bytes,4,opt,name=tool_status,json=toolStatus,proto3" json:"tool_status,omitempty"` + ToolStatusIsCompaction bool `protobuf:"varint,5,opt,name=tool_status_is_compaction,json=toolStatusIsCompaction,proto3" json:"tool_status_is_compaction,omitempty"` + AsOfSeq int64 `protobuf:"varint,6,opt,name=as_of_seq,json=asOfSeq,proto3" json:"as_of_seq,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatRunSnapshot) Reset() { + *x = ChatRunSnapshot{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatRunSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatRunSnapshot) ProtoMessage() {} + +func (x *ChatRunSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_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 ChatRunSnapshot.ProtoReflect.Descriptor instead. +func (*ChatRunSnapshot) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{16} +} + +func (x *ChatRunSnapshot) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ChatRunSnapshot) GetRevision() int64 { + if x != nil { + return x.Revision + } + return 0 +} + +func (x *ChatRunSnapshot) GetEntriesJson() string { + if x != nil { + return x.EntriesJson + } + return "" +} + +func (x *ChatRunSnapshot) GetToolStatus() string { + if x != nil { + return x.ToolStatus + } + return "" +} + +func (x *ChatRunSnapshot) GetToolStatusIsCompaction() bool { + if x != nil { + return x.ToolStatusIsCompaction + } + return false +} + +func (x *ChatRunSnapshot) GetAsOfSeq() int64 { + if x != nil { + return x.AsOfSeq + } + return 0 +} + +// ChatSubscribeResult 是 chat_subscribe 的响应。 +type ChatSubscribeResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + StreamEpoch string `protobuf:"bytes,2,opt,name=stream_epoch,json=streamEpoch,proto3" json:"stream_epoch,omitempty"` + LatestSeq int64 `protobuf:"varint,3,opt,name=latest_seq,json=latestSeq,proto3" json:"latest_seq,omitempty"` + Reset_ bool `protobuf:"varint,4,opt,name=reset,proto3" json:"reset,omitempty"` + Activity *ChatRunActivity `protobuf:"bytes,5,opt,name=activity,proto3" json:"activity,omitempty"` + Snapshot *ChatRunSnapshot `protobuf:"bytes,6,opt,name=snapshot,proto3" json:"snapshot,omitempty"` + // 回放的事件序列。载荷由 chatwire 塑形为深度动态 JSON,与 v1 一致按字节携带、不 proto 化 + // (重建模会分叉 chatwire 且对压缩后的二进制帧无实际收益)。 + EventsJson [][]byte `protobuf:"bytes,7,rep,name=events_json,json=eventsJson,proto3" json:"events_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatSubscribeResult) Reset() { + *x = ChatSubscribeResult{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatSubscribeResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatSubscribeResult) ProtoMessage() {} + +func (x *ChatSubscribeResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_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 ChatSubscribeResult.ProtoReflect.Descriptor instead. +func (*ChatSubscribeResult) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{17} +} + +func (x *ChatSubscribeResult) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatSubscribeResult) GetStreamEpoch() string { + if x != nil { + return x.StreamEpoch + } + return "" +} + +func (x *ChatSubscribeResult) GetLatestSeq() int64 { + if x != nil { + return x.LatestSeq + } + return 0 +} + +func (x *ChatSubscribeResult) GetReset_() bool { + if x != nil { + return x.Reset_ + } + return false +} + +func (x *ChatSubscribeResult) GetActivity() *ChatRunActivity { + if x != nil { + return x.Activity + } + return nil +} + +func (x *ChatSubscribeResult) GetSnapshot() *ChatRunSnapshot { + if x != nil { + return x.Snapshot + } + return nil +} + +func (x *ChatSubscribeResult) GetEventsJson() [][]byte { + if x != nil { + return x.EventsJson + } + return nil +} + +// ChatUnsubscribeRequest 取消订阅(对应 v1 "chat.unsubscribe");响应 AckResult。 +type ChatUnsubscribeRequest 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 *ChatUnsubscribeRequest) Reset() { + *x = ChatUnsubscribeRequest{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatUnsubscribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatUnsubscribeRequest) ProtoMessage() {} + +func (x *ChatUnsubscribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_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 ChatUnsubscribeRequest.ProtoReflect.Descriptor instead. +func (*ChatUnsubscribeRequest) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{18} +} + +func (x *ChatUnsubscribeRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +// ChatActivitiesRequest 查询运行中会话(对应 v1 "chat.activities");仅由网关状态应答,桌面端离线时亦可用。 +type ChatActivitiesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatActivitiesRequest) Reset() { + *x = ChatActivitiesRequest{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatActivitiesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatActivitiesRequest) ProtoMessage() {} + +func (x *ChatActivitiesRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_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 ChatActivitiesRequest.ProtoReflect.Descriptor instead. +func (*ChatActivitiesRequest) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{19} +} + +type ChatActivitiesResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunningConversations []*ChatRunActivity `protobuf:"bytes,1,rep,name=running_conversations,json=runningConversations,proto3" json:"running_conversations,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatActivitiesResult) Reset() { + *x = ChatActivitiesResult{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatActivitiesResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatActivitiesResult) ProtoMessage() {} + +func (x *ChatActivitiesResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[20] + 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 ChatActivitiesResult.ProtoReflect.Descriptor instead. +func (*ChatActivitiesResult) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{20} +} + +func (x *ChatActivitiesResult) GetRunningConversations() []*ChatRunActivity { + if x != nil { + return x.RunningConversations + } + return nil +} + +// ChatStreamEvent 是订阅后推送的单条会话事件。 +type ChatStreamEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + // 事件序号(与 payload_json 内的 seq 一致,便于不解析载荷即可去重)。 + Seq int64 `protobuf:"varint,2,opt,name=seq,proto3" json:"seq,omitempty"` + PayloadJson []byte `protobuf:"bytes,3,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatStreamEvent) Reset() { + *x = ChatStreamEvent{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatStreamEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatStreamEvent) ProtoMessage() {} + +func (x *ChatStreamEvent) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[21] + 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 ChatStreamEvent.ProtoReflect.Descriptor instead. +func (*ChatStreamEvent) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{21} +} + +func (x *ChatStreamEvent) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatStreamEvent) GetSeq() int64 { + if x != nil { + return x.Seq + } + return 0 +} + +func (x *ChatStreamEvent) GetPayloadJson() []byte { + if x != nil { + return x.PayloadJson + } + return nil +} + +// ChatCommandAccepted 是 chat_command 提交被接受的响应(对应 v1 chat.command 的应答形状)。 +type ChatCommandAccepted struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + ConversationId string `protobuf:"bytes,2,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + AcceptedSeq int64 `protobuf:"varint,3,opt,name=accepted_seq,json=acceptedSeq,proto3" json:"accepted_seq,omitempty"` + Deduped bool `protobuf:"varint,4,opt,name=deduped,proto3" json:"deduped,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatCommandAccepted) Reset() { + *x = ChatCommandAccepted{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatCommandAccepted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatCommandAccepted) ProtoMessage() {} + +func (x *ChatCommandAccepted) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[22] + 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 ChatCommandAccepted.ProtoReflect.Descriptor instead. +func (*ChatCommandAccepted) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{22} +} + +func (x *ChatCommandAccepted) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ChatCommandAccepted) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatCommandAccepted) GetAcceptedSeq() int64 { + if x != nil { + return x.AcceptedSeq + } + return 0 +} + +func (x *ChatCommandAccepted) GetDeduped() bool { + if x != nil { + return x.Deduped + } + return false +} + +// ChatCommandUpdate 推送命令的前置阶段结果(bound / queued_in_gui / failed),镜像 session.ChatCommandUpdate。 +type ChatCommandUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_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"` + Phase string `protobuf:"bytes,4,opt,name=phase,proto3" json:"phase,omitempty"` + ErrorCode string `protobuf:"bytes,5,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + Message string `protobuf:"bytes,6,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatCommandUpdate) Reset() { + *x = ChatCommandUpdate{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatCommandUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatCommandUpdate) ProtoMessage() {} + +func (x *ChatCommandUpdate) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[23] + 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 ChatCommandUpdate.ProtoReflect.Descriptor instead. +func (*ChatCommandUpdate) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{23} +} + +func (x *ChatCommandUpdate) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ChatCommandUpdate) GetClientRequestId() string { + if x != nil { + return x.ClientRequestId + } + return "" +} + +func (x *ChatCommandUpdate) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatCommandUpdate) GetPhase() string { + if x != nil { + return x.Phase + } + return "" +} + +func (x *ChatCommandUpdate) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *ChatCommandUpdate) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// ChatSubscriptionReset 通知客户端某会话流已被限流丢弃,需重新订阅(after_seq 断点续传)。 +type ChatSubscriptionReset 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 *ChatSubscriptionReset) Reset() { + *x = ChatSubscriptionReset{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatSubscriptionReset) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatSubscriptionReset) ProtoMessage() {} + +func (x *ChatSubscriptionReset) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[24] + 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 ChatSubscriptionReset.ProtoReflect.Descriptor instead. +func (*ChatSubscriptionReset) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{24} +} + +func (x *ChatSubscriptionReset) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +// ChatCancelResult 是 chat.cancel 的响应。 +type ChatCancelResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + RunId string `protobuf:"bytes,2,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + ConversationId string `protobuf:"bytes,3,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatCancelResult) Reset() { + *x = ChatCancelResult{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatCancelResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatCancelResult) ProtoMessage() {} + +func (x *ChatCancelResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[25] + 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 ChatCancelResult.ProtoReflect.Descriptor instead. +func (*ChatCancelResult) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{25} +} + +func (x *ChatCancelResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *ChatCancelResult) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ChatCancelResult) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +// ChatActivityEvent 广播会话活动状态变化,镜像 session.ConversationActivityEvent。 +type ChatActivityEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + RunId string `protobuf:"bytes,2,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + ClientRequestId string `protobuf:"bytes,3,opt,name=client_request_id,json=clientRequestId,proto3" json:"client_request_id,omitempty"` + Running bool `protobuf:"varint,4,opt,name=running,proto3" json:"running,omitempty"` + State string `protobuf:"bytes,5,opt,name=state,proto3" json:"state,omitempty"` + Workdir string `protobuf:"bytes,6,opt,name=workdir,proto3" json:"workdir,omitempty"` + UpdatedAtMs int64 `protobuf:"varint,7,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatActivityEvent) Reset() { + *x = ChatActivityEvent{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatActivityEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatActivityEvent) ProtoMessage() {} + +func (x *ChatActivityEvent) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_proto_msgTypes[26] + 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 ChatActivityEvent.ProtoReflect.Descriptor instead. +func (*ChatActivityEvent) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{26} +} + +func (x *ChatActivityEvent) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatActivityEvent) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ChatActivityEvent) GetClientRequestId() string { + if x != nil { + return x.ClientRequestId + } + return "" +} + +func (x *ChatActivityEvent) GetRunning() bool { + if x != nil { + return x.Running + } + return false +} + +func (x *ChatActivityEvent) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *ChatActivityEvent) GetWorkdir() string { + if x != nil { + return x.Workdir + } + return "" +} + +func (x *ChatActivityEvent) GetUpdatedAtMs() int64 { + if x != nil { + return x.UpdatedAtMs + } + return 0 +} + +// WorkspaceSubscribeRequest 订阅工作区活动(对应 v1 "workspace.subscribe");响应 AckResult,事件经 workspace_activity 臂广播。 +type WorkspaceSubscribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Workdir string `protobuf:"bytes,1,opt,name=workdir,proto3" json:"workdir,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkspaceSubscribeRequest) Reset() { + *x = WorkspaceSubscribeRequest{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkspaceSubscribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkspaceSubscribeRequest) ProtoMessage() {} + +func (x *WorkspaceSubscribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_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 WorkspaceSubscribeRequest.ProtoReflect.Descriptor instead. +func (*WorkspaceSubscribeRequest) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{27} +} + +func (x *WorkspaceSubscribeRequest) GetWorkdir() string { + if x != nil { + return x.Workdir + } + return "" +} + +// WorkspaceUnsubscribeRequest 取消订阅;响应 AckResult。 +type WorkspaceUnsubscribeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Workdir string `protobuf:"bytes,1,opt,name=workdir,proto3" json:"workdir,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkspaceUnsubscribeRequest) Reset() { + *x = WorkspaceUnsubscribeRequest{} + mi := &file_proto_v2_gateway_ws_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkspaceUnsubscribeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkspaceUnsubscribeRequest) ProtoMessage() {} + +func (x *WorkspaceUnsubscribeRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v2_gateway_ws_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 WorkspaceUnsubscribeRequest.ProtoReflect.Descriptor instead. +func (*WorkspaceUnsubscribeRequest) Descriptor() ([]byte, []int) { + return file_proto_v2_gateway_ws_proto_rawDescGZIP(), []int{28} +} + +func (x *WorkspaceUnsubscribeRequest) GetWorkdir() string { + if x != nil { + return x.Workdir + } + return "" +} + +var File_proto_v2_gateway_ws_proto protoreflect.FileDescriptor + +const file_proto_v2_gateway_ws_proto_rawDesc = "" + + "\n" + + "\x19proto/v2/gateway_ws.proto\x12\x14liveagent.gateway.v2\x1a\x16proto/v1/gateway.proto\"\x8c\x02\n" + + "\vClientHello\x12)\n" + + "\x10protocol_version\x18\x01 \x01(\rR\x0fprotocolVersion\x124\n" + + "\x04role\x18\x02 \x01(\x0e2 .liveagent.gateway.v2.ClientRoleR\x04role\x12\x14\n" + + "\x05token\x18\x03 \x01(\tR\x05token\x12\x19\n" + + "\bagent_id\x18\x04 \x01(\tR\aagentId\x12#\n" + + "\ragent_version\x18\x05 \x01(\tR\fagentVersion\x12\x1f\n" + + "\vclient_name\x18\x06 \x01(\tR\n" + + "clientName\x12%\n" + + "\x0eclient_version\x18\a \x01(\tR\rclientVersion\"\xdd\x01\n" + + "\vServerHello\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1d\n" + + "\n" + + "session_id\x18\x03 \x01(\tR\tsessionId\x12\x1f\n" + + "\vserver_time\x18\x04 \x01(\x03R\n" + + "serverTime\x128\n" + + "\x18heartbeat_period_seconds\x18\x05 \x01(\rR\x16heartbeatPeriodSeconds\x12*\n" + + "\x11max_message_bytes\x18\x06 \x01(\x04R\x0fmaxMessageBytes\")\n" + + "\tPingFrame\x12\x1c\n" + + "\ttimestamp\x18\x01 \x01(\x03R\ttimestamp\")\n" + + "\tPongFrame\x12\x1c\n" + + "\ttimestamp\x18\x01 \x01(\x03R\ttimestamp\"\x1b\n" + + "\tAckResult\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\"\xb7\a\n" + + "\x0eWebClientFrame\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x129\n" + + "\x05hello\x18\x02 \x01(\v2!.liveagent.gateway.v2.ClientHelloH\x00R\x05hello\x12L\n" + + "\ragent_request\x18\x03 \x01(\v2%.liveagent.gateway.v1.GatewayEnvelopeH\x00R\fagentRequest\x12G\n" + + "\n" + + "status_get\x18\x04 \x01(\v2&.liveagent.gateway.v2.StatusGetRequestH\x00R\tstatusGet\x12M\n" + + "\fchat_command\x18\x05 \x01(\v2(.liveagent.gateway.v1.ChatCommandRequestH\x00R\vchatCommand\x12M\n" + + "\fchat_prepare\x18\x06 \x01(\v2(.liveagent.gateway.v2.ChatPrepareRequestH\x00R\vchatPrepare\x12S\n" + + "\x0echat_subscribe\x18\a \x01(\v2*.liveagent.gateway.v2.ChatSubscribeRequestH\x00R\rchatSubscribe\x12Y\n" + + "\x10chat_unsubscribe\x18\b \x01(\v2,.liveagent.gateway.v2.ChatUnsubscribeRequestH\x00R\x0fchatUnsubscribe\x12V\n" + + "\x0fchat_activities\x18\t \x01(\v2+.liveagent.gateway.v2.ChatActivitiesRequestH\x00R\x0echatActivities\x12b\n" + + "\x13workspace_subscribe\x18\n" + + " \x01(\v2/.liveagent.gateway.v2.WorkspaceSubscribeRequestH\x00R\x12workspaceSubscribe\x12h\n" + + "\x15workspace_unsubscribe\x18\v \x01(\v21.liveagent.gateway.v2.WorkspaceUnsubscribeRequestH\x00R\x14workspaceUnsubscribe\x125\n" + + "\x04pong\x18\f \x01(\v2\x1f.liveagent.gateway.v2.PongFrameH\x00R\x04pongB\t\n" + + "\apayload\"\xe5\r\n" + + "\x0eWebServerFrame\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x129\n" + + "\x05hello\x18\x02 \x01(\v2!.liveagent.gateway.v2.ServerHelloH\x00R\x05hello\x12L\n" + + "\x0eagent_response\x18\x03 \x01(\v2#.liveagent.gateway.v1.AgentEnvelopeH\x00R\ragentResponse\x12F\n" + + "\vlocal_error\x18\x04 \x01(\v2#.liveagent.gateway.v1.ErrorResponseH\x00R\n" + + "localError\x125\n" + + "\x04ping\x18\x05 \x01(\v2\x1f.liveagent.gateway.v2.PingFrameH\x00R\x04ping\x12;\n" + + "\x06status\x18\x06 \x01(\v2!.liveagent.gateway.v2.StatusEventH\x00R\x06status\x12T\n" + + "\x0fchat_subscribed\x18\a \x01(\v2).liveagent.gateway.v2.ChatSubscribeResultH\x00R\x0echatSubscribed\x12P\n" + + "\rchat_accepted\x18\b \x01(\v2).liveagent.gateway.v2.ChatCommandAcceptedH\x00R\fchatAccepted\x12U\n" + + "\x0fchat_activities\x18\t \x01(\v2*.liveagent.gateway.v2.ChatActivitiesResultH\x00R\x0echatActivities\x12F\n" + + "\n" + + "chat_event\x18\n" + + " \x01(\v2%.liveagent.gateway.v2.ChatStreamEventH\x00R\tchatEvent\x12Y\n" + + "\x13chat_command_update\x18\v \x01(\v2'.liveagent.gateway.v2.ChatCommandUpdateH\x00R\x11chatCommandUpdate\x12e\n" + + "\x17chat_subscription_reset\x18\f \x01(\v2+.liveagent.gateway.v2.ChatSubscriptionResetH\x00R\x15chatSubscriptionReset\x12N\n" + + "\rchat_activity\x18\r \x01(\v2'.liveagent.gateway.v2.ChatActivityEventH\x00R\fchatActivity\x123\n" + + "\x03ack\x18\x0e \x01(\v2\x1f.liveagent.gateway.v2.AckResultH\x00R\x03ack\x12O\n" + + "\x0echat_cancelled\x18\x0f \x01(\v2&.liveagent.gateway.v2.ChatCancelResultH\x00R\rchatCancelled\x12M\n" + + "\rhistory_event\x18\x14 \x01(\v2&.liveagent.gateway.v1.HistorySyncEventH\x00R\fhistoryEvent\x12P\n" + + "\x0esettings_event\x18\x15 \x01(\v2'.liveagent.gateway.v1.SettingsSyncEventH\x00R\rsettingsEvent\x12L\n" + + "\x0eterminal_event\x18\x16 \x01(\v2#.liveagent.gateway.v1.TerminalEventH\x00R\rterminalEvent\x12@\n" + + "\n" + + "sftp_event\x18\x17 \x01(\v2\x1f.liveagent.gateway.v1.SftpEventH\x00R\tsftpEvent\x12P\n" + + "\x10chat_queue_event\x18\x18 \x01(\v2$.liveagent.gateway.v1.ChatQueueEventH\x00R\x0echatQueueEvent\x12N\n" + + "\ftunnel_state\x18\x19 \x01(\v2).liveagent.gateway.v1.TunnelStateSnapshotH\x00R\vtunnelState\x12S\n" + + "\rprocess_state\x18\x1a \x01(\v2,.liveagent.gateway.v1.ManagedProcessSnapshotH\x00R\fprocessState\x12]\n" + + "\x12workspace_activity\x18\x1b \x01(\v2,.liveagent.gateway.v1.WorkspaceActivityEventH\x00R\x11workspaceActivityB\t\n" + + "\apayload\"\x9b\x01\n" + + "\x10AgentClientFrame\x129\n" + + "\x05hello\x18\x01 \x01(\v2!.liveagent.gateway.v2.ClientHelloH\x00R\x05hello\x12A\n" + + "\benvelope\x18\x02 \x01(\v2#.liveagent.gateway.v1.AgentEnvelopeH\x00R\benvelopeB\t\n" + + "\apayload\"\x9d\x01\n" + + "\x10AgentServerFrame\x129\n" + + "\x05hello\x18\x01 \x01(\v2!.liveagent.gateway.v2.ServerHelloH\x00R\x05hello\x12C\n" + + "\benvelope\x18\x02 \x01(\v2%.liveagent.gateway.v1.GatewayEnvelopeH\x00R\benvelopeB\t\n" + + "\apayload\"\x9e\x01\n" + + "\x13TerminalClientFrame\x129\n" + + "\x05hello\x18\x01 \x01(\v2!.liveagent.gateway.v2.ClientHelloH\x00R\x05hello\x12A\n" + + "\x05frame\x18\x02 \x01(\v2).liveagent.gateway.v1.TerminalStreamFrameH\x00R\x05frameB\t\n" + + "\apayload\"\x9e\x01\n" + + "\x13TerminalServerFrame\x129\n" + + "\x05hello\x18\x01 \x01(\v2!.liveagent.gateway.v2.ServerHelloH\x00R\x05hello\x12A\n" + + "\x05frame\x18\x02 \x01(\v2).liveagent.gateway.v1.TerminalStreamFrameH\x00R\x05frameB\t\n" + + "\apayload\"\x12\n" + + "\x10StatusGetRequest\"\x8c\x04\n" + + "\vStatusEvent\x12\x16\n" + + "\x06online\x18\x01 \x01(\bR\x06online\x12\x1f\n" + + "\vagent_ready\x18\x02 \x01(\bR\n" + + "agentReady\x12,\n" + + "\x12chat_runtime_ready\x18\x03 \x01(\bR\x10chatRuntimeReady\x12\x19\n" + + "\bagent_id\x18\x04 \x01(\tR\aagentId\x12#\n" + + "\ragent_version\x18\x05 \x01(\tR\fagentVersion\x12\x1d\n" + + "\n" + + "session_id\x18\x06 \x01(\tR\tsessionId\x12'\n" + + "\x0fconnected_since\x18\a \x01(\x03R\x0econnectedSince\x12%\n" + + "\x0elast_heartbeat\x18\b \x01(\x03R\rlastHeartbeat\x12#\n" + + "\rruntime_state\x18\t \x01(\tR\fruntimeState\x124\n" + + "\x16runtime_last_heartbeat\x18\n" + + " \x01(\x03R\x14runtimeLastHeartbeat\x12*\n" + + "\x11runtime_worker_id\x18\v \x01(\tR\x0fruntimeWorkerId\x12'\n" + + "\x0fruntime_visible\x18\f \x01(\bR\x0eruntimeVisible\x127\n" + + "\x18runtime_active_run_count\x18\r \x01(\rR\x15runtimeActiveRunCount\",\n" + + "\x12ChatPrepareRequest\x12\x16\n" + + "\x06reason\x18\x01 \x01(\tR\x06reason\"\x7f\n" + + "\x14ChatSubscribeRequest\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x1b\n" + + "\tafter_seq\x18\x02 \x01(\x03R\bafterSeq\x12!\n" + + "\fstream_epoch\x18\x03 \x01(\tR\vstreamEpoch\"\xce\x02\n" + + "\x0fChatRunActivity\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\x12\x14\n" + + "\x05state\x18\x02 \x01(\tR\x05state\x12\x1f\n" + + "\vstarted_seq\x18\x03 \x01(\x03R\n" + + "startedSeq\x12\"\n" + + "\rupdated_at_ms\x18\x04 \x01(\x03R\vupdatedAtMs\x12\x1f\n" + + "\vtool_status\x18\x05 \x01(\tR\n" + + "toolStatus\x129\n" + + "\x19tool_status_is_compaction\x18\x06 \x01(\bR\x16toolStatusIsCompaction\x12*\n" + + "\x11client_request_id\x18\a \x01(\tR\x0fclientRequestId\x12'\n" + + "\x0fconversation_id\x18\b \x01(\tR\x0econversationId\x12\x18\n" + + "\aworkdir\x18\t \x01(\tR\aworkdir\"\xdf\x01\n" + + "\x0fChatRunSnapshot\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\x12\x1a\n" + + "\brevision\x18\x02 \x01(\x03R\brevision\x12!\n" + + "\fentries_json\x18\x03 \x01(\tR\ventriesJson\x12\x1f\n" + + "\vtool_status\x18\x04 \x01(\tR\n" + + "toolStatus\x129\n" + + "\x19tool_status_is_compaction\x18\x05 \x01(\bR\x16toolStatusIsCompaction\x12\x1a\n" + + "\tas_of_seq\x18\x06 \x01(\x03R\aasOfSeq\"\xbd\x02\n" + + "\x13ChatSubscribeResult\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12!\n" + + "\fstream_epoch\x18\x02 \x01(\tR\vstreamEpoch\x12\x1d\n" + + "\n" + + "latest_seq\x18\x03 \x01(\x03R\tlatestSeq\x12\x14\n" + + "\x05reset\x18\x04 \x01(\bR\x05reset\x12A\n" + + "\bactivity\x18\x05 \x01(\v2%.liveagent.gateway.v2.ChatRunActivityR\bactivity\x12A\n" + + "\bsnapshot\x18\x06 \x01(\v2%.liveagent.gateway.v2.ChatRunSnapshotR\bsnapshot\x12\x1f\n" + + "\vevents_json\x18\a \x03(\fR\n" + + "eventsJson\"A\n" + + "\x16ChatUnsubscribeRequest\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\"\x17\n" + + "\x15ChatActivitiesRequest\"r\n" + + "\x14ChatActivitiesResult\x12Z\n" + + "\x15running_conversations\x18\x01 \x03(\v2%.liveagent.gateway.v2.ChatRunActivityR\x14runningConversations\"o\n" + + "\x0fChatStreamEvent\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x10\n" + + "\x03seq\x18\x02 \x01(\x03R\x03seq\x12!\n" + + "\fpayload_json\x18\x03 \x01(\fR\vpayloadJson\"\x92\x01\n" + + "\x13ChatCommandAccepted\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\x12'\n" + + "\x0fconversation_id\x18\x02 \x01(\tR\x0econversationId\x12!\n" + + "\faccepted_seq\x18\x03 \x01(\x03R\vacceptedSeq\x12\x18\n" + + "\adeduped\x18\x04 \x01(\bR\adeduped\"\xce\x01\n" + + "\x11ChatCommandUpdate\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\x12*\n" + + "\x11client_request_id\x18\x02 \x01(\tR\x0fclientRequestId\x12'\n" + + "\x0fconversation_id\x18\x03 \x01(\tR\x0econversationId\x12\x14\n" + + "\x05phase\x18\x04 \x01(\tR\x05phase\x12\x1d\n" + + "\n" + + "error_code\x18\x05 \x01(\tR\terrorCode\x12\x18\n" + + "\amessage\x18\x06 \x01(\tR\amessage\"@\n" + + "\x15ChatSubscriptionReset\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\"b\n" + + "\x10ChatCancelResult\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x15\n" + + "\x06run_id\x18\x02 \x01(\tR\x05runId\x12'\n" + + "\x0fconversation_id\x18\x03 \x01(\tR\x0econversationId\"\xed\x01\n" + + "\x11ChatActivityEvent\x12'\n" + + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x15\n" + + "\x06run_id\x18\x02 \x01(\tR\x05runId\x12*\n" + + "\x11client_request_id\x18\x03 \x01(\tR\x0fclientRequestId\x12\x18\n" + + "\arunning\x18\x04 \x01(\bR\arunning\x12\x14\n" + + "\x05state\x18\x05 \x01(\tR\x05state\x12\x18\n" + + "\aworkdir\x18\x06 \x01(\tR\aworkdir\x12\"\n" + + "\rupdated_at_ms\x18\a \x01(\x03R\vupdatedAtMs\"5\n" + + "\x19WorkspaceSubscribeRequest\x12\x18\n" + + "\aworkdir\x18\x01 \x01(\tR\aworkdir\"7\n" + + "\x1bWorkspaceUnsubscribeRequest\x12\x18\n" + + "\aworkdir\x18\x01 \x01(\tR\aworkdir*Y\n" + + "\n" + + "ClientRole\x12\x1b\n" + + "\x17CLIENT_ROLE_UNSPECIFIED\x10\x00\x12\x17\n" + + "\x13CLIENT_ROLE_BROWSER\x10\x01\x12\x15\n" + + "\x11CLIENT_ROLE_AGENT\x10\x02B@Z>github.com/liveagent/agent-gateway/internal/proto/v2;gatewayv2b\x06proto3" + +var ( + file_proto_v2_gateway_ws_proto_rawDescOnce sync.Once + file_proto_v2_gateway_ws_proto_rawDescData []byte +) + +func file_proto_v2_gateway_ws_proto_rawDescGZIP() []byte { + file_proto_v2_gateway_ws_proto_rawDescOnce.Do(func() { + file_proto_v2_gateway_ws_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_v2_gateway_ws_proto_rawDesc), len(file_proto_v2_gateway_ws_proto_rawDesc))) + }) + return file_proto_v2_gateway_ws_proto_rawDescData +} + +var file_proto_v2_gateway_ws_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_proto_v2_gateway_ws_proto_msgTypes = make([]protoimpl.MessageInfo, 29) +var file_proto_v2_gateway_ws_proto_goTypes = []any{ + (ClientRole)(0), // 0: liveagent.gateway.v2.ClientRole + (*ClientHello)(nil), // 1: liveagent.gateway.v2.ClientHello + (*ServerHello)(nil), // 2: liveagent.gateway.v2.ServerHello + (*PingFrame)(nil), // 3: liveagent.gateway.v2.PingFrame + (*PongFrame)(nil), // 4: liveagent.gateway.v2.PongFrame + (*AckResult)(nil), // 5: liveagent.gateway.v2.AckResult + (*WebClientFrame)(nil), // 6: liveagent.gateway.v2.WebClientFrame + (*WebServerFrame)(nil), // 7: liveagent.gateway.v2.WebServerFrame + (*AgentClientFrame)(nil), // 8: liveagent.gateway.v2.AgentClientFrame + (*AgentServerFrame)(nil), // 9: liveagent.gateway.v2.AgentServerFrame + (*TerminalClientFrame)(nil), // 10: liveagent.gateway.v2.TerminalClientFrame + (*TerminalServerFrame)(nil), // 11: liveagent.gateway.v2.TerminalServerFrame + (*StatusGetRequest)(nil), // 12: liveagent.gateway.v2.StatusGetRequest + (*StatusEvent)(nil), // 13: liveagent.gateway.v2.StatusEvent + (*ChatPrepareRequest)(nil), // 14: liveagent.gateway.v2.ChatPrepareRequest + (*ChatSubscribeRequest)(nil), // 15: liveagent.gateway.v2.ChatSubscribeRequest + (*ChatRunActivity)(nil), // 16: liveagent.gateway.v2.ChatRunActivity + (*ChatRunSnapshot)(nil), // 17: liveagent.gateway.v2.ChatRunSnapshot + (*ChatSubscribeResult)(nil), // 18: liveagent.gateway.v2.ChatSubscribeResult + (*ChatUnsubscribeRequest)(nil), // 19: liveagent.gateway.v2.ChatUnsubscribeRequest + (*ChatActivitiesRequest)(nil), // 20: liveagent.gateway.v2.ChatActivitiesRequest + (*ChatActivitiesResult)(nil), // 21: liveagent.gateway.v2.ChatActivitiesResult + (*ChatStreamEvent)(nil), // 22: liveagent.gateway.v2.ChatStreamEvent + (*ChatCommandAccepted)(nil), // 23: liveagent.gateway.v2.ChatCommandAccepted + (*ChatCommandUpdate)(nil), // 24: liveagent.gateway.v2.ChatCommandUpdate + (*ChatSubscriptionReset)(nil), // 25: liveagent.gateway.v2.ChatSubscriptionReset + (*ChatCancelResult)(nil), // 26: liveagent.gateway.v2.ChatCancelResult + (*ChatActivityEvent)(nil), // 27: liveagent.gateway.v2.ChatActivityEvent + (*WorkspaceSubscribeRequest)(nil), // 28: liveagent.gateway.v2.WorkspaceSubscribeRequest + (*WorkspaceUnsubscribeRequest)(nil), // 29: liveagent.gateway.v2.WorkspaceUnsubscribeRequest + (*v1.GatewayEnvelope)(nil), // 30: liveagent.gateway.v1.GatewayEnvelope + (*v1.ChatCommandRequest)(nil), // 31: liveagent.gateway.v1.ChatCommandRequest + (*v1.AgentEnvelope)(nil), // 32: liveagent.gateway.v1.AgentEnvelope + (*v1.ErrorResponse)(nil), // 33: liveagent.gateway.v1.ErrorResponse + (*v1.HistorySyncEvent)(nil), // 34: liveagent.gateway.v1.HistorySyncEvent + (*v1.SettingsSyncEvent)(nil), // 35: liveagent.gateway.v1.SettingsSyncEvent + (*v1.TerminalEvent)(nil), // 36: liveagent.gateway.v1.TerminalEvent + (*v1.SftpEvent)(nil), // 37: liveagent.gateway.v1.SftpEvent + (*v1.ChatQueueEvent)(nil), // 38: liveagent.gateway.v1.ChatQueueEvent + (*v1.TunnelStateSnapshot)(nil), // 39: liveagent.gateway.v1.TunnelStateSnapshot + (*v1.ManagedProcessSnapshot)(nil), // 40: liveagent.gateway.v1.ManagedProcessSnapshot + (*v1.WorkspaceActivityEvent)(nil), // 41: liveagent.gateway.v1.WorkspaceActivityEvent + (*v1.TerminalStreamFrame)(nil), // 42: liveagent.gateway.v1.TerminalStreamFrame +} +var file_proto_v2_gateway_ws_proto_depIdxs = []int32{ + 0, // 0: liveagent.gateway.v2.ClientHello.role:type_name -> liveagent.gateway.v2.ClientRole + 1, // 1: liveagent.gateway.v2.WebClientFrame.hello:type_name -> liveagent.gateway.v2.ClientHello + 30, // 2: liveagent.gateway.v2.WebClientFrame.agent_request:type_name -> liveagent.gateway.v1.GatewayEnvelope + 12, // 3: liveagent.gateway.v2.WebClientFrame.status_get:type_name -> liveagent.gateway.v2.StatusGetRequest + 31, // 4: liveagent.gateway.v2.WebClientFrame.chat_command:type_name -> liveagent.gateway.v1.ChatCommandRequest + 14, // 5: liveagent.gateway.v2.WebClientFrame.chat_prepare:type_name -> liveagent.gateway.v2.ChatPrepareRequest + 15, // 6: liveagent.gateway.v2.WebClientFrame.chat_subscribe:type_name -> liveagent.gateway.v2.ChatSubscribeRequest + 19, // 7: liveagent.gateway.v2.WebClientFrame.chat_unsubscribe:type_name -> liveagent.gateway.v2.ChatUnsubscribeRequest + 20, // 8: liveagent.gateway.v2.WebClientFrame.chat_activities:type_name -> liveagent.gateway.v2.ChatActivitiesRequest + 28, // 9: liveagent.gateway.v2.WebClientFrame.workspace_subscribe:type_name -> liveagent.gateway.v2.WorkspaceSubscribeRequest + 29, // 10: liveagent.gateway.v2.WebClientFrame.workspace_unsubscribe:type_name -> liveagent.gateway.v2.WorkspaceUnsubscribeRequest + 4, // 11: liveagent.gateway.v2.WebClientFrame.pong:type_name -> liveagent.gateway.v2.PongFrame + 2, // 12: liveagent.gateway.v2.WebServerFrame.hello:type_name -> liveagent.gateway.v2.ServerHello + 32, // 13: liveagent.gateway.v2.WebServerFrame.agent_response:type_name -> liveagent.gateway.v1.AgentEnvelope + 33, // 14: liveagent.gateway.v2.WebServerFrame.local_error:type_name -> liveagent.gateway.v1.ErrorResponse + 3, // 15: liveagent.gateway.v2.WebServerFrame.ping:type_name -> liveagent.gateway.v2.PingFrame + 13, // 16: liveagent.gateway.v2.WebServerFrame.status:type_name -> liveagent.gateway.v2.StatusEvent + 18, // 17: liveagent.gateway.v2.WebServerFrame.chat_subscribed:type_name -> liveagent.gateway.v2.ChatSubscribeResult + 23, // 18: liveagent.gateway.v2.WebServerFrame.chat_accepted:type_name -> liveagent.gateway.v2.ChatCommandAccepted + 21, // 19: liveagent.gateway.v2.WebServerFrame.chat_activities:type_name -> liveagent.gateway.v2.ChatActivitiesResult + 22, // 20: liveagent.gateway.v2.WebServerFrame.chat_event:type_name -> liveagent.gateway.v2.ChatStreamEvent + 24, // 21: liveagent.gateway.v2.WebServerFrame.chat_command_update:type_name -> liveagent.gateway.v2.ChatCommandUpdate + 25, // 22: liveagent.gateway.v2.WebServerFrame.chat_subscription_reset:type_name -> liveagent.gateway.v2.ChatSubscriptionReset + 27, // 23: liveagent.gateway.v2.WebServerFrame.chat_activity:type_name -> liveagent.gateway.v2.ChatActivityEvent + 5, // 24: liveagent.gateway.v2.WebServerFrame.ack:type_name -> liveagent.gateway.v2.AckResult + 26, // 25: liveagent.gateway.v2.WebServerFrame.chat_cancelled:type_name -> liveagent.gateway.v2.ChatCancelResult + 34, // 26: liveagent.gateway.v2.WebServerFrame.history_event:type_name -> liveagent.gateway.v1.HistorySyncEvent + 35, // 27: liveagent.gateway.v2.WebServerFrame.settings_event:type_name -> liveagent.gateway.v1.SettingsSyncEvent + 36, // 28: liveagent.gateway.v2.WebServerFrame.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent + 37, // 29: liveagent.gateway.v2.WebServerFrame.sftp_event:type_name -> liveagent.gateway.v1.SftpEvent + 38, // 30: liveagent.gateway.v2.WebServerFrame.chat_queue_event:type_name -> liveagent.gateway.v1.ChatQueueEvent + 39, // 31: liveagent.gateway.v2.WebServerFrame.tunnel_state:type_name -> liveagent.gateway.v1.TunnelStateSnapshot + 40, // 32: liveagent.gateway.v2.WebServerFrame.process_state:type_name -> liveagent.gateway.v1.ManagedProcessSnapshot + 41, // 33: liveagent.gateway.v2.WebServerFrame.workspace_activity:type_name -> liveagent.gateway.v1.WorkspaceActivityEvent + 1, // 34: liveagent.gateway.v2.AgentClientFrame.hello:type_name -> liveagent.gateway.v2.ClientHello + 32, // 35: liveagent.gateway.v2.AgentClientFrame.envelope:type_name -> liveagent.gateway.v1.AgentEnvelope + 2, // 36: liveagent.gateway.v2.AgentServerFrame.hello:type_name -> liveagent.gateway.v2.ServerHello + 30, // 37: liveagent.gateway.v2.AgentServerFrame.envelope:type_name -> liveagent.gateway.v1.GatewayEnvelope + 1, // 38: liveagent.gateway.v2.TerminalClientFrame.hello:type_name -> liveagent.gateway.v2.ClientHello + 42, // 39: liveagent.gateway.v2.TerminalClientFrame.frame:type_name -> liveagent.gateway.v1.TerminalStreamFrame + 2, // 40: liveagent.gateway.v2.TerminalServerFrame.hello:type_name -> liveagent.gateway.v2.ServerHello + 42, // 41: liveagent.gateway.v2.TerminalServerFrame.frame:type_name -> liveagent.gateway.v1.TerminalStreamFrame + 16, // 42: liveagent.gateway.v2.ChatSubscribeResult.activity:type_name -> liveagent.gateway.v2.ChatRunActivity + 17, // 43: liveagent.gateway.v2.ChatSubscribeResult.snapshot:type_name -> liveagent.gateway.v2.ChatRunSnapshot + 16, // 44: liveagent.gateway.v2.ChatActivitiesResult.running_conversations:type_name -> liveagent.gateway.v2.ChatRunActivity + 45, // [45:45] is the sub-list for method output_type + 45, // [45:45] is the sub-list for method input_type + 45, // [45:45] is the sub-list for extension type_name + 45, // [45:45] is the sub-list for extension extendee + 0, // [0:45] is the sub-list for field type_name +} + +func init() { file_proto_v2_gateway_ws_proto_init() } +func file_proto_v2_gateway_ws_proto_init() { + if File_proto_v2_gateway_ws_proto != nil { + return + } + file_proto_v2_gateway_ws_proto_msgTypes[5].OneofWrappers = []any{ + (*WebClientFrame_Hello)(nil), + (*WebClientFrame_AgentRequest)(nil), + (*WebClientFrame_StatusGet)(nil), + (*WebClientFrame_ChatCommand)(nil), + (*WebClientFrame_ChatPrepare)(nil), + (*WebClientFrame_ChatSubscribe)(nil), + (*WebClientFrame_ChatUnsubscribe)(nil), + (*WebClientFrame_ChatActivities)(nil), + (*WebClientFrame_WorkspaceSubscribe)(nil), + (*WebClientFrame_WorkspaceUnsubscribe)(nil), + (*WebClientFrame_Pong)(nil), + } + file_proto_v2_gateway_ws_proto_msgTypes[6].OneofWrappers = []any{ + (*WebServerFrame_Hello)(nil), + (*WebServerFrame_AgentResponse)(nil), + (*WebServerFrame_LocalError)(nil), + (*WebServerFrame_Ping)(nil), + (*WebServerFrame_Status)(nil), + (*WebServerFrame_ChatSubscribed)(nil), + (*WebServerFrame_ChatAccepted)(nil), + (*WebServerFrame_ChatActivities)(nil), + (*WebServerFrame_ChatEvent)(nil), + (*WebServerFrame_ChatCommandUpdate)(nil), + (*WebServerFrame_ChatSubscriptionReset)(nil), + (*WebServerFrame_ChatActivity)(nil), + (*WebServerFrame_Ack)(nil), + (*WebServerFrame_ChatCancelled)(nil), + (*WebServerFrame_HistoryEvent)(nil), + (*WebServerFrame_SettingsEvent)(nil), + (*WebServerFrame_TerminalEvent)(nil), + (*WebServerFrame_SftpEvent)(nil), + (*WebServerFrame_ChatQueueEvent)(nil), + (*WebServerFrame_TunnelState)(nil), + (*WebServerFrame_ProcessState)(nil), + (*WebServerFrame_WorkspaceActivity)(nil), + } + file_proto_v2_gateway_ws_proto_msgTypes[7].OneofWrappers = []any{ + (*AgentClientFrame_Hello)(nil), + (*AgentClientFrame_Envelope)(nil), + } + file_proto_v2_gateway_ws_proto_msgTypes[8].OneofWrappers = []any{ + (*AgentServerFrame_Hello)(nil), + (*AgentServerFrame_Envelope)(nil), + } + file_proto_v2_gateway_ws_proto_msgTypes[9].OneofWrappers = []any{ + (*TerminalClientFrame_Hello)(nil), + (*TerminalClientFrame_Frame)(nil), + } + file_proto_v2_gateway_ws_proto_msgTypes[10].OneofWrappers = []any{ + (*TerminalServerFrame_Hello)(nil), + (*TerminalServerFrame_Frame)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_v2_gateway_ws_proto_rawDesc), len(file_proto_v2_gateway_ws_proto_rawDesc)), + NumEnums: 1, + NumMessages: 29, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_proto_v2_gateway_ws_proto_goTypes, + DependencyIndexes: file_proto_v2_gateway_ws_proto_depIdxs, + EnumInfos: file_proto_v2_gateway_ws_proto_enumTypes, + MessageInfos: file_proto_v2_gateway_ws_proto_msgTypes, + }.Build() + File_proto_v2_gateway_ws_proto = out.File + file_proto_v2_gateway_ws_proto_goTypes = nil + file_proto_v2_gateway_ws_proto_depIdxs = nil +} diff --git a/crates/agent-gateway/proto/v1/gateway.proto b/crates/agent-gateway/proto/v1/gateway.proto index 99dfb4cf1..78baa1aeb 100644 --- a/crates/agent-gateway/proto/v1/gateway.proto +++ b/crates/agent-gateway/proto/v1/gateway.proto @@ -4,7 +4,12 @@ package liveagent.gateway.v1; option go_package = "github.com/liveagent/agent-gateway/internal/proto/v1;gatewayv1"; +// AgentGateway 是 v1 的桌面端 gRPC 服务。已弃用:v2 起由 /ws/v2/agent 与 /ws/v2/terminal +// (见 proto/v2/gateway_ws.proto)承担,仅为旧版桌面客户端保留,流量归零后删除; +// 本文件的消息是 v2 的业务载荷,全部保留、永不弃用。 service AgentGateway { + option deprecated = true; + rpc AgentConnect(stream AgentEnvelope) returns (stream GatewayEnvelope); rpc AgentTerminalConnect(stream TerminalStreamFrame) returns (stream TerminalStreamFrame); rpc Authenticate(AuthRequest) returns (AuthResponse); @@ -599,6 +604,8 @@ message ChatMessageRef { message CancelChatRequest { string conversation_id = 1; + // 可选运行 id 提示:v2 浏览器链路用它消除同会话并发运行的歧义;桌面端可忽略。 + string run_id = 2; } message ChatCommandRequest { diff --git a/crates/agent-gateway/proto/v2/gateway_ws.proto b/crates/agent-gateway/proto/v2/gateway_ws.proto new file mode 100644 index 000000000..e1725a021 --- /dev/null +++ b/crates/agent-gateway/proto/v2/gateway_ws.proto @@ -0,0 +1,313 @@ +// v2 统一线协议(WebSocket+Protobuf):/ws/v2(浏览器)、/ws/v2/agent(桌面端)、 +// /ws/v2/terminal(终端数据面)三链路的帧壳。一条 WS 二进制消息承载一条帧,文本帧即协议错误; +// 首帧必须为 hello,鉴权失败以 close code 4401 关闭。业务载荷复用 v1 消息(三端唯一事实源, +// 永不弃用、编号只增不改),本文件仅新增帧壳与未曾 proto 化的网关本地载荷。 +syntax = "proto3"; + +package liveagent.gateway.v2; + +import "proto/v1/gateway.proto"; + +option go_package = "github.com/liveagent/agent-gateway/internal/proto/v2;gatewayv2"; + +// --------------------------------------------------------------------------- +// 握手 +// --------------------------------------------------------------------------- + +// ClientRole 区分 /ws/v2/terminal 上连接的所属端(该链路两端共用一条路径,靠 hello.role 区分)。 +enum ClientRole { + CLIENT_ROLE_UNSPECIFIED = 0; + CLIENT_ROLE_BROWSER = 1; + CLIENT_ROLE_AGENT = 2; +} + +// ClientHello 是所有 v2 连接的第一帧。 +message ClientHello { + // 协议版本,当前恒为 2;未知版本被服务端拒绝。 + uint32 protocol_version = 1; + ClientRole role = 2; + // 网关访问令牌,服务端做常量时间比较。 + string token = 3; + // 以下字段仅 CLIENT_ROLE_AGENT 使用(对应 v1 gRPC AuthRequest)。 + string agent_id = 4; + string agent_version = 5; + // 客户端标识(如 "webui" / "desktop"),仅用于观测与日志。 + string client_name = 6; + string client_version = 7; +} + +// ServerHello 是服务端对 ClientHello 的应答;ok=false 时随即关闭连接。 +message ServerHello { + bool ok = 1; + string message = 2; + // 仅 agent 角色返回(对应 v1 gRPC AuthResponse.session_id)。 + string session_id = 3; + // 服务端 Unix 秒时间戳,供客户端校准。 + int64 server_time = 4; + // 服务端心跳周期与消息大小上限,客户端应据此配置本地看门狗与分片。 + uint32 heartbeat_period_seconds = 5; + uint64 max_message_bytes = 6; +} + +// PingFrame / PongFrame 是应用层心跳:WS 控制帧 ping 探测网络栈,本帧探测页面 JS/事件循环存活。 +message PingFrame { + int64 timestamp = 1; +} + +message PongFrame { + int64 timestamp = 1; +} + +// AckResult 是本地操作的通用确认应答(对应 v1 的 {"ok":true})。 +message AckResult { + bool ok = 1; +} + +// --------------------------------------------------------------------------- +// 浏览器链路(/ws/v2) +// --------------------------------------------------------------------------- + +// WebClientFrame 为浏览器 → 网关方向的帧。除 agent_request 直通臂外,其余臂均为 +// 网关本地操作(由网关自身状态应答,不经桌面端往返)。 +message WebClientFrame { + // 请求关联 id,客户端生成、响应帧回携;广播事件与心跳帧为空。 + string request_id = 1; + + oneof payload { + ClientHello hello = 2; + // 直通请求:网关校验白名单与限额后转发桌面端(request_id 按连接命名空间化防冲突)。 + liveagent.gateway.v1.GatewayEnvelope agent_request = 3; + StatusGetRequest status_get = 4; + liveagent.gateway.v1.ChatCommandRequest chat_command = 5; + ChatPrepareRequest chat_prepare = 6; + ChatSubscribeRequest chat_subscribe = 7; + ChatUnsubscribeRequest chat_unsubscribe = 8; + ChatActivitiesRequest chat_activities = 9; + WorkspaceSubscribeRequest workspace_subscribe = 10; + WorkspaceUnsubscribeRequest workspace_unsubscribe = 11; + PongFrame pong = 12; + } +} + +// WebServerFrame 为网关 → 浏览器方向的帧。 +message WebServerFrame { + // 关联响应回填请求 id;服务端主动推送(广播/心跳)时为空。 + string request_id = 1; + + oneof payload { + ServerHello hello = 2; + // 直通响应:桌面端返回的 v1 信封(含 error=99 错误臂)。 + liveagent.gateway.v1.AgentEnvelope agent_response = 3; + // 网关本地错误(鉴权、校验、离线等;对应 v1 的 type:"error" 信封)。 + liveagent.gateway.v1.ErrorResponse local_error = 4; + PingFrame ping = 5; + // status_get / chat_prepare 的响应与 status 广播共用,新旧客户端共享一个状态归一化器。 + StatusEvent status = 6; + ChatSubscribeResult chat_subscribed = 7; + ChatCommandAccepted chat_accepted = 8; + ChatActivitiesResult chat_activities = 9; + ChatStreamEvent chat_event = 10; + ChatCommandUpdate chat_command_update = 11; + ChatSubscriptionReset chat_subscription_reset = 12; + ChatActivityEvent chat_activity = 13; + AckResult ack = 14; + ChatCancelResult chat_cancelled = 15; + + // 广播事件:session 层吐出的 v1 seam 消息零塑形直转。 + liveagent.gateway.v1.HistorySyncEvent history_event = 20; + liveagent.gateway.v1.SettingsSyncEvent settings_event = 21; + liveagent.gateway.v1.TerminalEvent terminal_event = 22; + liveagent.gateway.v1.SftpEvent sftp_event = 23; + liveagent.gateway.v1.ChatQueueEvent chat_queue_event = 24; + liveagent.gateway.v1.TunnelStateSnapshot tunnel_state = 25; + liveagent.gateway.v1.ManagedProcessSnapshot process_state = 26; + liveagent.gateway.v1.WorkspaceActivityEvent workspace_activity = 27; + } +} + +// --------------------------------------------------------------------------- +// 桌面端链路(/ws/v2/agent,替代 v1 gRPC Authenticate + AgentConnect) +// --------------------------------------------------------------------------- + +// AgentClientFrame 为桌面端 → 网关方向的帧。 +message AgentClientFrame { + oneof payload { + ClientHello hello = 1; + liveagent.gateway.v1.AgentEnvelope envelope = 2; + } +} + +// AgentServerFrame 为网关 → 桌面端方向的帧。 +message AgentServerFrame { + oneof payload { + ServerHello hello = 1; + liveagent.gateway.v1.GatewayEnvelope envelope = 2; + } +} + +// --------------------------------------------------------------------------- +// 终端链路(/ws/v2/terminal,两端共用;替代浏览器自定义二进制帧与 v1 gRPC AgentTerminalConnect) +// --------------------------------------------------------------------------- + +// TerminalClientFrame 为客户端(浏览器或桌面端)→ 网关方向的帧。 +message TerminalClientFrame { + oneof payload { + ClientHello hello = 1; + liveagent.gateway.v1.TerminalStreamFrame frame = 2; + } +} + +// TerminalServerFrame 为网关 → 客户端方向的帧。 +message TerminalServerFrame { + oneof payload { + ServerHello hello = 1; + liveagent.gateway.v1.TerminalStreamFrame frame = 2; + } +} + +// --------------------------------------------------------------------------- +// 网关本地载荷(v1 JSON 协议时代以 Go 结构体/手写 map 存在,此处 proto 化) +// --------------------------------------------------------------------------- + +// StatusGetRequest 请求网关侧运行状态快照(对应 v1 "status.get")。 +message StatusGetRequest {} + +// StatusEvent 镜像 session.Status 的 JSON 形状(字段一一对应)。 +message StatusEvent { + bool online = 1; + bool agent_ready = 2; + bool chat_runtime_ready = 3; + string agent_id = 4; + string agent_version = 5; + string session_id = 6; + int64 connected_since = 7; + int64 last_heartbeat = 8; + string runtime_state = 9; + int64 runtime_last_heartbeat = 10; + string runtime_worker_id = 11; + bool runtime_visible = 12; + uint32 runtime_active_run_count = 13; +} + +// ChatPrepareRequest 唤醒/探活桌面端 chat 运行时(对应 v1 "chat.prepare");响应为 StatusEvent。 +message ChatPrepareRequest { + string reason = 1; +} + +// ChatSubscribeRequest 订阅会话事件流(对应 v1 "chat.subscribe")。after_seq + stream_epoch +// 支持断线重放:epoch 不匹配或序号超界时服务端置 reset 并从头回放。 +message ChatSubscribeRequest { + string conversation_id = 1; + int64 after_seq = 2; + string stream_epoch = 3; +} + +// ChatRunActivity 镜像 session.RunActivity 的 JSON 形状。 +message ChatRunActivity { + string run_id = 1; + string state = 2; + int64 started_seq = 3; + // Unix 毫秒时间戳。 + int64 updated_at_ms = 4; + string tool_status = 5; + bool tool_status_is_compaction = 6; + string client_request_id = 7; + // 以下两个字段仅在 chat_activities 列表中填充。 + string conversation_id = 8; + string workdir = 9; +} + +// ChatRunSnapshot 镜像 session.RunSnapshot 的 JSON 形状。 +message ChatRunSnapshot { + string run_id = 1; + int64 revision = 2; + // 桌面端渲染快照,内容为动态 JSON(与 v1 一致按字符串携带,不建模)。 + string entries_json = 3; + string tool_status = 4; + bool tool_status_is_compaction = 5; + int64 as_of_seq = 6; +} + +// ChatSubscribeResult 是 chat_subscribe 的响应。 +message ChatSubscribeResult { + string conversation_id = 1; + string stream_epoch = 2; + int64 latest_seq = 3; + bool reset = 4; + ChatRunActivity activity = 5; + ChatRunSnapshot snapshot = 6; + // 回放的事件序列。载荷由 chatwire 塑形为深度动态 JSON,与 v1 一致按字节携带、不 proto 化 + // (重建模会分叉 chatwire 且对压缩后的二进制帧无实际收益)。 + repeated bytes events_json = 7; +} + +// ChatUnsubscribeRequest 取消订阅(对应 v1 "chat.unsubscribe");响应 AckResult。 +message ChatUnsubscribeRequest { + string conversation_id = 1; +} + +// ChatActivitiesRequest 查询运行中会话(对应 v1 "chat.activities");仅由网关状态应答,桌面端离线时亦可用。 +message ChatActivitiesRequest {} + +message ChatActivitiesResult { + repeated ChatRunActivity running_conversations = 1; +} + +// ChatStreamEvent 是订阅后推送的单条会话事件。 +message ChatStreamEvent { + string conversation_id = 1; + // 事件序号(与 payload_json 内的 seq 一致,便于不解析载荷即可去重)。 + int64 seq = 2; + bytes payload_json = 3; +} + +// ChatCommandAccepted 是 chat_command 提交被接受的响应(对应 v1 chat.command 的应答形状)。 +message ChatCommandAccepted { + string run_id = 1; + string conversation_id = 2; + int64 accepted_seq = 3; + bool deduped = 4; +} + +// ChatCommandUpdate 推送命令的前置阶段结果(bound / queued_in_gui / failed),镜像 session.ChatCommandUpdate。 +message ChatCommandUpdate { + string run_id = 1; + string client_request_id = 2; + string conversation_id = 3; + string phase = 4; + string error_code = 5; + string message = 6; +} + +// ChatSubscriptionReset 通知客户端某会话流已被限流丢弃,需重新订阅(after_seq 断点续传)。 +message ChatSubscriptionReset { + string conversation_id = 1; +} + +// ChatCancelResult 是 chat.cancel 的响应。 +message ChatCancelResult { + bool ok = 1; + string run_id = 2; + string conversation_id = 3; +} + +// ChatActivityEvent 广播会话活动状态变化,镜像 session.ConversationActivityEvent。 +message ChatActivityEvent { + string conversation_id = 1; + string run_id = 2; + string client_request_id = 3; + bool running = 4; + string state = 5; + string workdir = 6; + int64 updated_at_ms = 7; +} + +// WorkspaceSubscribeRequest 订阅工作区活动(对应 v1 "workspace.subscribe");响应 AckResult,事件经 workspace_activity 臂广播。 +message WorkspaceSubscribeRequest { + string workdir = 1; +} + +// WorkspaceUnsubscribeRequest 取消订阅;响应 AckResult。 +message WorkspaceUnsubscribeRequest { + string workdir = 1; +} diff --git a/crates/agent-gui/src-tauri/build.rs b/crates/agent-gui/src-tauri/build.rs index 6f59117ba..4ac058a4d 100644 --- a/crates/agent-gui/src-tauri/build.rs +++ b/crates/agent-gui/src-tauri/build.rs @@ -25,20 +25,22 @@ fn main() { }); println!("cargo:rustc-env=LIVEAGENT_APP_VERSION={app_version}"); - let proto_dir = std::path::Path::new(&manifest_dir) + // v1/v2 proto 共用 agent-gateway 目录为 include 根,import 路径与 Go 侧 buf 模块一致; + // v2 经该路径 import v1 复用其消息。 + let gateway_root = std::path::Path::new(&manifest_dir) .join("..") .join("..") - .join("agent-gateway") - .join("proto") - .join("v1"); - let proto_file = proto_dir.join("gateway.proto"); + .join("agent-gateway"); + let proto_v1 = gateway_root.join("proto").join("v1").join("gateway.proto"); + let proto_v2 = gateway_root.join("proto").join("v2").join("gateway_ws.proto"); - println!("cargo:rerun-if-changed={}", proto_file.display()); + println!("cargo:rerun-if-changed={}", proto_v1.display()); + println!("cargo:rerun-if-changed={}", proto_v2.display()); tonic_prost_build::configure() .build_server(false) - .compile_protos(&[proto_file], &[proto_dir]) - .expect("compile gateway proto"); + .compile_protos(&[proto_v1, proto_v2], &[gateway_root]) + .expect("compile gateway protos"); tauri_build::build() } diff --git a/crates/agent-gui/src-tauri/src/services/gateway/mod.rs b/crates/agent-gui/src-tauri/src/services/gateway/mod.rs index 28d850f0d..249157b93 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/mod.rs @@ -29,10 +29,21 @@ use crate::services::memory::MemoryStore; use crate::services::tunnel::{TunnelProxy, TunnelStore}; use crate::services::workspace_watch::WorkspaceWatchService; -pub mod proto { - tonic::include_proto!("liveagent.gateway.v1"); +/// 网关 proto 生成模块。v2 帧壳经 prost `super::v1::` 路径复用 v1 业务消息,两版本必须并列嵌套。 +pub mod gateway_proto { + pub mod v1 { + tonic::include_proto!("liveagent.gateway.v1"); + } + // v2 整包生成,浏览器链路专用帧类型桌面端不构造:抑制 dead_code。 + #[allow(dead_code)] + pub mod v2 { + tonic::include_proto!("liveagent.gateway.v2"); + } } +/// 兼容别名:既有代码一律以 `proto::` 引用 v1 业务消息,别名使其零改动。 +pub use gateway_proto::v1 as proto; + mod chat; mod chat_inbox; mod connection; @@ -47,6 +58,7 @@ mod terminal; mod tests; mod types; mod util; +mod ws_transport; pub(crate) use chat::*; pub(crate) use chat_inbox::*; @@ -58,6 +70,7 @@ pub(crate) use sftp::*; pub(crate) use terminal::*; pub use types::*; pub(crate) use util::*; +pub(crate) use ws_transport::*; pub(crate) const UI_ONLY_SETTINGS_SYNC_FIELDS: &[&str] = &[ "skills", @@ -76,6 +89,12 @@ pub(crate) const GATEWAY_OUTBOUND_CONTROL_QUEUE_DEPTH: usize = 64; pub(crate) const GATEWAY_RECONNECT_MIN: Duration = Duration::from_millis(250); pub(crate) const GATEWAY_RECONNECT_MAX: Duration = Duration::from_secs(5); pub(crate) const GATEWAY_RECONNECT_STABLE_AFTER: Duration = Duration::from_secs(30); +// v2 主链路存活看门狗(取代 v1 gRPC 的 h2 keepalive):ServerHello 未给心跳周期时的回退值, +// 以及静默超 3×心跳周期发 WS Ping 探活后的宽限时长。 +pub(crate) const GATEWAY_WS_DEFAULT_HEARTBEAT_PERIOD: Duration = Duration::from_secs(30); +pub(crate) const GATEWAY_WS_PROBE_GRACE: Duration = Duration::from_secs(10); +// v1 回退会话的升级重试窗口:回退可能只是瞬时抖动,到点主动重拨让 v2 恢复,避免长驻弃用链路。 +pub(crate) const GATEWAY_WS_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(10 * 60); pub(crate) const GATEWAY_POST_CONNECT_REPLAY_DELAY: Duration = Duration::from_millis(200); pub(crate) const GATEWAY_TERMINAL_STREAM_RECONNECT_MIN: Duration = Duration::from_millis(250); pub(crate) const GATEWAY_TERMINAL_STREAM_RECONNECT_MAX: Duration = Duration::from_secs(5); From 3d6fc5cd3383147e43ac19e9e185be4dff475439 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:19:24 +0800 Subject: [PATCH 04/10] refactor(gateway): extract protocol-agnostic runtime and domain logic - internal/transport/wscore: priority dual-queue write pump, shedding, retries, heartbeat, idle eviction (behavior-identical port, tests moved) - internal/protocol/shared: origin check, terminal gating/finalize, interest tracker - internal/chatcmd: chat command orchestration (normalize/probe/dispatch/watchdog) - session.AwaitUnaryResponse: shared unary request/response primitive - v1 server rewired onto the shared pieces; behavior unchanged --- .../chat_commands.go => chatcmd/chatcmd.go} | 192 ++++--- .../chatcmd_test.go} | 30 +- .../internal/protocol/shared/origin.go | 98 ++++ .../shared/terminal_interest.go} | 27 +- .../protocol/shared/terminal_interest_test.go | 40 ++ .../protocol/shared/terminal_relay.go | 193 +++++++ .../internal/server/http_origin.go | 93 +--- .../internal/server/websocket.go | 491 +++++------------- .../server/websocket_chat_handlers.go | 26 +- .../server/websocket_chat_shed_test.go | 21 +- .../server/websocket_connection_state_test.go | 36 +- .../server/websocket_history_handlers.go | 20 +- .../internal/server/websocket_roundtrip.go | 27 +- .../server/websocket_terminal_handlers.go | 152 +----- .../server/websocket_terminal_stream.go | 11 +- .../internal/server/websocket_write_test.go | 134 ++--- .../agent-gateway/internal/session/unary.go | 33 ++ .../internal/transport/wscore/conn.go | 303 +++++++++++ .../internal/transport/wscore/conn_test.go | 169 ++++++ .../internal/transport/wscore/frame.go | 36 ++ .../internal/transport/wscore/heartbeat.go | 44 ++ 21 files changed, 1336 insertions(+), 840 deletions(-) rename crates/agent-gateway/internal/{server/chat_commands.go => chatcmd/chatcmd.go} (56%) rename crates/agent-gateway/internal/{server/chat_commands_test.go => chatcmd/chatcmd_test.go} (73%) create mode 100644 crates/agent-gateway/internal/protocol/shared/origin.go rename crates/agent-gateway/internal/{server/websocket_connection_state.go => protocol/shared/terminal_interest.go} (56%) create mode 100644 crates/agent-gateway/internal/protocol/shared/terminal_interest_test.go create mode 100644 crates/agent-gateway/internal/protocol/shared/terminal_relay.go create mode 100644 crates/agent-gateway/internal/session/unary.go create mode 100644 crates/agent-gateway/internal/transport/wscore/conn.go create mode 100644 crates/agent-gateway/internal/transport/wscore/conn_test.go create mode 100644 crates/agent-gateway/internal/transport/wscore/frame.go create mode 100644 crates/agent-gateway/internal/transport/wscore/heartbeat.go diff --git a/crates/agent-gateway/internal/server/chat_commands.go b/crates/agent-gateway/internal/chatcmd/chatcmd.go similarity index 56% rename from crates/agent-gateway/internal/server/chat_commands.go rename to crates/agent-gateway/internal/chatcmd/chatcmd.go index aada5c25b..9be12a608 100644 --- a/crates/agent-gateway/internal/server/chat_commands.go +++ b/crates/agent-gateway/internal/chatcmd/chatcmd.go @@ -1,4 +1,7 @@ -package server +// Package chatcmd 承载网关侧 chat 命令编排(请求体归一化、运行时探活、命令投递与启动看门狗、 +// proto 信封构造);v1/v2 协议层共用(自 internal/server/chat_commands.go 平移,行为不变), +// 协议层只做载荷编解码,编排逻辑一律收敛于此。 +package chatcmd import ( "bytes" @@ -6,7 +9,7 @@ import ( "encoding/json" "errors" "io" - "log" + "log/slog" "strings" "time" @@ -17,7 +20,8 @@ import ( "github.com/liveagent/agent-gateway/internal/session" ) -type chatCommandMessageRef struct { +// MessageRef 是 chat.edit_resend 引用的既有消息定位(JSON 线格式与 v1 一致)。 +type MessageRef struct { SegmentIndex int `json:"segment_index"` MessageIndex int `json:"message_index"` SegmentID string `json:"segment_id"` @@ -27,15 +31,18 @@ type chatCommandMessageRef struct { } const ( - chatRuntimeWakeRequestPrefix = "chat-runtime-wake-" - chatRuntimeProbeReuseWindow = 2 * time.Second + // runtimeWakeRequestPrefix 是探活请求 id 的约定前缀;桌面端识别到它会先唤醒 Chat WebView 运行时。 + runtimeWakeRequestPrefix = "chat-runtime-wake-" + runtimeProbeReuseWindow = 2 * time.Second ) -func newChatTraceID() string { +// NewTraceID 生成 chat 命令链路的追踪 id。 +func NewTraceID() string { return strings.ReplaceAll(uuid.NewString(), "-", "") } -func logChatCommandSpan( +// LogCommandSpan 记录 chat 命令生命周期中的一个阶段(结构化日志)。 +func LogCommandSpan( traceID string, span string, runID string, @@ -43,24 +50,24 @@ func logChatCommandSpan( clientRequestID string, commandType string, ) { - log.Printf( - "chat_command_span span=%s trace_id=%s run_id=%q conversation_id=%q client_request_id=%q command_type=%q", - strings.TrimSpace(span), - strings.TrimSpace(traceID), - strings.TrimSpace(runID), - strings.TrimSpace(conversationID), - strings.TrimSpace(clientRequestID), - strings.TrimSpace(commandType), + slog.Info("chat_command_span", + "span", strings.TrimSpace(span), + "trace_id", strings.TrimSpace(traceID), + "run_id", strings.TrimSpace(runID), + "conversation_id", strings.TrimSpace(conversationID), + "client_request_id", strings.TrimSpace(clientRequestID), + "command_type", strings.TrimSpace(commandType), ) } -func normalizeChatRequestBody(body *handler.ChatRequestBody) error { +// NormalizeRequestBody 归一化并校验 chat 请求体(trim、默认值、必填项)。 +func NormalizeRequestBody(body *handler.ChatRequestBody) error { body.Message = strings.TrimSpace(body.Message) body.ConversationID = strings.TrimSpace(body.ConversationID) body.ClientRequestID = strings.TrimSpace(body.ClientRequestID) body.ExecutionMode = handler.NormalizeExecutionMode(body.ExecutionMode) body.Workdir = handler.NormalizeWorkdir(body.Workdir) - body.QueuePolicy = normalizeChatQueuePolicy(body.QueuePolicy) + body.QueuePolicy = normalizeQueuePolicy(body.QueuePolicy) body.SelectedSystemTools = handler.NormalizeSelectedSystemTools(body.SelectedSystemTools) body.UploadedFiles = handler.NormalizeChatUploadedFiles(body.UploadedFiles) body.RuntimeControls = handler.NormalizeChatRuntimeControls(body.RuntimeControls) @@ -78,7 +85,7 @@ func normalizeChatRequestBody(body *handler.ChatRequestBody) error { return nil } -func normalizeChatQueuePolicy(value string) string { +func normalizeQueuePolicy(value string) string { switch strings.TrimSpace(value) { case "append", "interrupt": return strings.TrimSpace(value) @@ -87,23 +94,22 @@ func normalizeChatQueuePolicy(value string) string { } } -// dispatchAcceptedChatCommand delivers the accepted command to the agent and -// arms the startup watchdog. cleanupWatch closes the caller's command-update -// watch once the command has either settled or been failed. -func dispatchAcceptedChatCommand( +// DispatchAcceptedCommand 把已接受的命令投递给桌面端并布防启动看门狗; +// cleanupWatch 在命令落定或判失败后关闭调用方的命令更新观察流。 +func DispatchAcceptedCommand( parent context.Context, cfg *config.Config, sm *session.Manager, cleanupWatch func(), start session.ChatCommandStart, body handler.ChatRequestBody, - baseMessageRef *chatCommandMessageRef, + baseMessageRef *MessageRef, traceID string, ) { if cleanupWatch != nil { defer cleanupWatch() } - timeout := chatDeliveryTimeout(cfg) + timeout := DeliveryTimeout(cfg) ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() @@ -111,7 +117,7 @@ func dispatchAcceptedChatCommand( if baseMessageRef != nil { commandType = "chat.edit_resend" } - if err := sm.SendToAgentContext(ctx, buildChatCommandEnvelope(start.RunID, commandType, body, baseMessageRef)); err != nil { + if err := sm.SendToAgentContext(ctx, buildCommandEnvelope(start.RunID, commandType, body, baseMessageRef)); err != nil { message := "chat command failed" if err != nil && strings.TrimSpace(err.Error()) != "" { message = strings.TrimSpace(err.Error()) @@ -119,15 +125,13 @@ func dispatchAcceptedChatCommand( sm.FailChatCommand(start.RunID, "desktop_runtime_unavailable", message) return } - logChatCommandSpan(traceID, "command_delivered", start.RunID, start.ConversationID, body.ClientRequestID, commandType) - watchAcceptedChatCommandStartup(parent, cfg, sm, start.RunID) + LogCommandSpan(traceID, "command_delivered", start.RunID, start.ConversationID, body.ClientRequestID, commandType) + WatchAcceptedCommandStartup(parent, cfg, sm, start.RunID) } -// probeChatRuntime verifies that the current desktop AgentConnect stream can -// complete a real round trip. The special request-id prefix is also the native -// desktop's signal to nudge the Chat WebView runtime before a command is -// accepted. -func probeChatRuntime( +// ProbeRuntime 验证桌面端连接可完成真实往返;探活请求 id 的特殊前缀同时是唤醒 +// Chat WebView 运行时的信号。 +func ProbeRuntime( ctx context.Context, sm *session.Manager, ) error { @@ -138,8 +142,8 @@ func probeChatRuntime( if !online { return session.ErrAgentOffline } - requestID := chatRuntimeWakeRequestPrefix + uuid.NewString() - response, err := awaitAgentUnaryResponse(ctx, sm, requestID, &gatewayv1.GatewayEnvelope{ + requestID := runtimeWakeRequestPrefix + uuid.NewString() + response, err := sm.AwaitUnaryResponse(ctx, requestID, &gatewayv1.GatewayEnvelope{ RequestId: requestID, Timestamp: time.Now().Unix(), Payload: &gatewayv1.GatewayEnvelope_Ping{ @@ -158,17 +162,16 @@ func probeChatRuntime( return nil } -func probeChatRuntimeForCommand(ctx context.Context, sm *session.Manager) error { - if sm != nil && sm.ChatRuntimeProbeFresh(chatRuntimeProbeReuseWindow) { +// ProbeRuntimeForCommand 在近期已有成功探活时直接复用结果。 +func ProbeRuntimeForCommand(ctx context.Context, sm *session.Manager) error { + if sm != nil && sm.ChatRuntimeProbeFresh(runtimeProbeReuseWindow) { return nil } - return probeChatRuntime(ctx, sm) + return ProbeRuntime(ctx, sm) } -// watchAcceptedChatCommandStartup fails a command whose run never settled -// (started, finished, or parked in the desktop prompt queue) within the -// configured startup window. -func watchAcceptedChatCommandStartup( +// WatchAcceptedCommandStartup 对启动窗口内未落定(开始、结束或进入桌面提示队列)的命令判失败。 +func WatchAcceptedCommandStartup( parent context.Context, cfg *config.Config, sm *session.Manager, @@ -177,13 +180,13 @@ func watchAcceptedChatCommandStartup( if sm == nil || strings.TrimSpace(runID) == "" { return } - if !waitChatCommandWatchdog(parent, chatStartTimeout(cfg)) { + if !waitCommandWatchdog(parent, StartTimeout(cfg)) { return } if sm.ChatCommandSettled(runID) { return } - if !waitChatCommandWatchdog(parent, chatRenderStartTimeout(cfg)) { + if !waitCommandWatchdog(parent, RenderStartTimeout(cfg)) { return } if sm.ChatCommandSettled(runID) { @@ -193,7 +196,7 @@ func watchAcceptedChatCommandStartup( "The desktop app did not start the remote chat request. Please retry.") } -func waitChatCommandWatchdog(ctx context.Context, timeout time.Duration) bool { +func waitCommandWatchdog(ctx context.Context, timeout time.Duration) bool { if timeout <= 0 { return true } @@ -207,37 +210,41 @@ func waitChatCommandWatchdog(ctx context.Context, timeout time.Duration) bool { } } -func chatStartTimeout(cfg *config.Config) time.Duration { +// StartTimeout / RenderStartTimeout / PrepareTimeout / DeliveryTimeout 返回各阶段超时 +// (未配置时取保守默认值)。 +func StartTimeout(cfg *config.Config) time.Duration { if cfg != nil && cfg.ChatStartTimeout > 0 { return cfg.ChatStartTimeout } return 5 * time.Second } -func chatRenderStartTimeout(cfg *config.Config) time.Duration { +func RenderStartTimeout(cfg *config.Config) time.Duration { if cfg != nil && cfg.ChatRenderStartTimeout > 0 { return cfg.ChatRenderStartTimeout } return 10 * time.Second } -func chatPrepareTimeout(cfg *config.Config) time.Duration { +func PrepareTimeout(cfg *config.Config) time.Duration { if cfg != nil && cfg.ChatPrepareTimeout > 0 { return cfg.ChatPrepareTimeout } return 2 * time.Second } -func chatDeliveryTimeout(cfg *config.Config) time.Duration { +func DeliveryTimeout(cfg *config.Config) time.Duration { if cfg != nil && cfg.ChatDeliveryTimeout > 0 { return cfg.ChatDeliveryTimeout } return 5 * time.Second } -func buildAcceptedChatCommandPayloads( +// BuildAcceptedCommandPayloads 构造命令被接受时立即写入会话流的事件载荷 +// (edit_resend 先补一条 rebase 事件)。 +func BuildAcceptedCommandPayloads( body handler.ChatRequestBody, - baseMessageRef *chatCommandMessageRef, + baseMessageRef *MessageRef, ) []map[string]any { payloads := make([]map[string]any, 0, 2) if baseMessageRef != nil { @@ -253,7 +260,7 @@ func buildAcceptedChatCommandPayloads( func buildUserMessageAppendedPayload( body handler.ChatRequestBody, - baseMessageRef *chatCommandMessageRef, + baseMessageRef *MessageRef, ) map[string]any { payload := map[string]any{ "type": "user_message", @@ -272,11 +279,11 @@ func buildUserMessageAppendedPayload( return payload } -func buildChatCommandEnvelope( +func buildCommandEnvelope( requestID string, commandType string, body handler.ChatRequestBody, - baseMessageRef *chatCommandMessageRef, + baseMessageRef *MessageRef, ) *gatewayv1.GatewayEnvelope { return &gatewayv1.GatewayEnvelope{ RequestId: strings.TrimSpace(requestID), @@ -284,14 +291,15 @@ func buildChatCommandEnvelope( Payload: &gatewayv1.GatewayEnvelope_ChatCommand{ ChatCommand: &gatewayv1.ChatCommandRequest{ Type: strings.TrimSpace(commandType), - Request: buildProtoChatRequest(body), - BaseMessageRef: buildProtoChatMessageRef(baseMessageRef), + Request: buildProtoRequest(body), + BaseMessageRef: BuildProtoMessageRef(baseMessageRef), }, }, } } -func buildChatCancelCommandPayload(conversationID string) *gatewayv1.GatewayEnvelope_ChatCommand { +// BuildCancelCommandPayload 构造 chat.cancel 的 GatewayEnvelope 载荷臂。 +func BuildCancelCommandPayload(conversationID string) *gatewayv1.GatewayEnvelope_ChatCommand { return &gatewayv1.GatewayEnvelope_ChatCommand{ ChatCommand: &gatewayv1.ChatCommandRequest{ Type: "chat.cancel", @@ -302,7 +310,7 @@ func buildChatCancelCommandPayload(conversationID string) *gatewayv1.GatewayEnve } } -func buildProtoChatRequest(body handler.ChatRequestBody) *gatewayv1.ChatRequest { +func buildProtoRequest(body handler.ChatRequestBody) *gatewayv1.ChatRequest { return &gatewayv1.ChatRequest{ ConversationId: body.ConversationID, ClientRequestId: body.ClientRequestID, @@ -317,7 +325,8 @@ func buildProtoChatRequest(body handler.ChatRequestBody) *gatewayv1.ChatRequest } } -func buildProtoChatMessageRef(ref *chatCommandMessageRef) *gatewayv1.ChatMessageRef { +// BuildProtoMessageRef 把 MessageRef 转为 proto 表示(nil 安全)。 +func BuildProtoMessageRef(ref *MessageRef) *gatewayv1.ChatMessageRef { if ref == nil { return nil } @@ -331,7 +340,66 @@ func buildProtoChatMessageRef(ref *chatCommandMessageRef) *gatewayv1.ChatMessage } } -func validateChatMessageRef(ref *chatCommandMessageRef) error { +// RequestBodyFromProto 把 v2 直带的 proto ChatRequest 还原为编排层请求体 +// (buildProtoRequest 的逆向;调用方随后统一走 NormalizeRequestBody)。 +func RequestBodyFromProto(req *gatewayv1.ChatRequest) handler.ChatRequestBody { + if req == nil { + return handler.ChatRequestBody{} + } + body := handler.ChatRequestBody{ + ConversationID: req.GetConversationId(), + ClientRequestID: req.GetClientRequestId(), + Message: req.GetMessage(), + ExecutionMode: req.GetExecutionMode(), + Workdir: req.GetWorkdir(), + SelectedSystemTools: req.GetSelectedSystemTools(), + QueuePolicy: req.GetQueuePolicy(), + } + if selected := req.GetSelectedModel(); selected != nil { + body.SelectedModel = &handler.ChatSelectedModelBody{ + CustomProviderID: selected.GetCustomProviderId(), + Model: selected.GetModel(), + ProviderType: selected.GetProviderType(), + } + } + if controls := req.GetRuntimeControls(); controls != nil { + thinking := controls.GetThinkingEnabled() + webSearch := controls.GetNativeWebSearchEnabled() + body.RuntimeControls = &handler.ChatRuntimeControlsBody{ + ThinkingEnabled: &thinking, + NativeWebSearchEnabled: &webSearch, + Reasoning: controls.GetReasoning(), + } + } + for _, file := range req.GetUploadedFiles() { + body.UploadedFiles = append(body.UploadedFiles, handler.ChatUploadedFileBody{ + RelativePath: file.GetRelativePath(), + AbsolutePath: file.GetAbsolutePath(), + FileName: file.GetFileName(), + Kind: file.GetKind(), + SizeBytes: file.GetSizeBytes(), + }) + } + return body +} + +// MessageRefFromProto 把 proto 消息引用还原为编排层表示(nil 安全)。 +func MessageRefFromProto(ref *gatewayv1.ChatMessageRef) *MessageRef { + if ref == nil { + return nil + } + return &MessageRef{ + SegmentIndex: int(ref.GetSegmentIndex()), + MessageIndex: int(ref.GetMessageIndex()), + SegmentID: ref.GetSegmentId(), + MessageID: ref.GetMessageId(), + Role: ref.GetRole(), + ContentHash: ref.GetContentHash(), + } +} + +// ValidateMessageRef 校验并归一化消息引用(原地 trim)。 +func ValidateMessageRef(ref *MessageRef) error { if ref == nil { return nil } @@ -351,13 +419,15 @@ func validateChatMessageRef(ref *chatCommandMessageRef) error { return nil } -func decodeChatCommandPayload(raw json.RawMessage) (string, handler.ChatRequestBody, *chatCommandMessageRef, error) { +// DecodeCommandPayload 解码 v1 JSON 线格式的 chat.command 载荷({type, payload:{...}}); +// v2 直带 proto ChatCommandRequest,不经此路。 +func DecodeCommandPayload(raw json.RawMessage) (string, handler.ChatRequestBody, *MessageRef, error) { type commandEnvelope struct { Type string `json:"type"` Payload json.RawMessage `json:"payload"` } type commandPayload struct { - BaseMessageRef *chatCommandMessageRef `json:"base_message_ref,omitempty"` + BaseMessageRef *MessageRef `json:"base_message_ref,omitempty"` handler.ChatRequestBody } diff --git a/crates/agent-gateway/internal/server/chat_commands_test.go b/crates/agent-gateway/internal/chatcmd/chatcmd_test.go similarity index 73% rename from crates/agent-gateway/internal/server/chat_commands_test.go rename to crates/agent-gateway/internal/chatcmd/chatcmd_test.go index b5f6811c6..85a345277 100644 --- a/crates/agent-gateway/internal/server/chat_commands_test.go +++ b/crates/agent-gateway/internal/chatcmd/chatcmd_test.go @@ -1,4 +1,4 @@ -package server +package chatcmd import ( "context" @@ -10,7 +10,7 @@ import ( "github.com/liveagent/agent-gateway/internal/session" ) -func newChatCommandTestManager(t *testing.T) (*session.Manager, *session.AgentSession) { +func newCommandTestManager(t *testing.T) (*session.Manager, *session.AgentSession) { t.Helper() sm := session.NewManager() sm.RecordAuthentication("desktop-agent", "test", "session-test") @@ -23,24 +23,24 @@ func newChatCommandTestManager(t *testing.T) (*session.Manager, *session.AgentSe func TestChatTimeoutDefaultsAreShortAndDedicated(t *testing.T) { t.Parallel() - if got := chatPrepareTimeout(nil); got != 2*time.Second { - t.Fatalf("chatPrepareTimeout(nil) = %s, want 2s", got) + if got := PrepareTimeout(nil); got != 2*time.Second { + t.Fatalf("PrepareTimeout(nil) = %s, want 2s", got) } - if got := chatDeliveryTimeout(nil); got != 5*time.Second { - t.Fatalf("chatDeliveryTimeout(nil) = %s, want 5s", got) + if got := DeliveryTimeout(nil); got != 5*time.Second { + t.Fatalf("DeliveryTimeout(nil) = %s, want 5s", got) } - if got := chatStartTimeout(nil); got != 5*time.Second { - t.Fatalf("chatStartTimeout(nil) = %s, want 5s", got) + if got := StartTimeout(nil); got != 5*time.Second { + t.Fatalf("StartTimeout(nil) = %s, want 5s", got) } - if got := chatRenderStartTimeout(nil); got != 10*time.Second { - t.Fatalf("chatRenderStartTimeout(nil) = %s, want 10s", got) + if got := RenderStartTimeout(nil); got != 10*time.Second { + t.Fatalf("RenderStartTimeout(nil) = %s, want 10s", got) } } -func TestDispatchAcceptedChatCommandUsesDeliveryTimeout(t *testing.T) { +func TestDispatchAcceptedCommandUsesDeliveryTimeout(t *testing.T) { t.Parallel() - sm, _ := newChatCommandTestManager(t) + sm, _ := newCommandTestManager(t) start := sm.StartChatCommand("run-delivery-timeout", "conv-1", "", "client-1", nil) cfg := &config.Config{ChatDeliveryTimeout: 30 * time.Millisecond} body := handler.ChatRequestBody{ @@ -50,7 +50,7 @@ func TestDispatchAcceptedChatCommandUsesDeliveryTimeout(t *testing.T) { } startedAt := time.Now() - dispatchAcceptedChatCommand( + DispatchAcceptedCommand( context.Background(), cfg, sm, nil, start, body, nil, "trace-delivery-timeout", ) elapsed := time.Since(startedAt) @@ -73,7 +73,7 @@ func TestDispatchAcceptedChatCommandUsesDeliveryTimeout(t *testing.T) { func TestChatStartupWatchdogUsesShortCombinedWindow(t *testing.T) { t.Parallel() - sm, _ := newChatCommandTestManager(t) + sm, _ := newCommandTestManager(t) sm.StartChatCommand("run-start-timeout", "conv-1", "", "client-1", nil) cfg := &config.Config{ ChatStartTimeout: 15 * time.Millisecond, @@ -81,7 +81,7 @@ func TestChatStartupWatchdogUsesShortCombinedWindow(t *testing.T) { } startedAt := time.Now() - watchAcceptedChatCommandStartup(context.Background(), cfg, sm, "run-start-timeout") + WatchAcceptedCommandStartup(context.Background(), cfg, sm, "run-start-timeout") elapsed := time.Since(startedAt) if elapsed < 25*time.Millisecond || elapsed > 500*time.Millisecond { t.Fatalf("startup watchdog elapsed = %s, want about 35ms", elapsed) diff --git a/crates/agent-gateway/internal/protocol/shared/origin.go b/crates/agent-gateway/internal/protocol/shared/origin.go new file mode 100644 index 000000000..e9af3ca11 --- /dev/null +++ b/crates/agent-gateway/internal/protocol/shared/origin.go @@ -0,0 +1,98 @@ +package shared + +import ( + "net" + "net/http" + "net/url" + "strings" +) + +// OriginAllowed 是 WebSocket 升级的同源校验(v1/v2 共用,自 internal/server/http_origin.go +// 平移,行为不变):无 Origin 头(非浏览器)、同源、或两端均为回环地址(本机开发)时放行。 +func OriginAllowed(r *http.Request) bool { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return true + } + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return false + } + requestURL := requestURLForOriginCheck(r) + if requestURL == nil { + return false + } + if sameOrigin(parsed, requestURL) { + return true + } + originHost := strings.TrimSpace(parsed.Hostname()) + requestHost := strings.TrimSpace(requestURL.Hostname()) + if originHost == "" || requestHost == "" { + return false + } + return isLoopbackHost(originHost) && isLoopbackHost(requestHost) +} + +func requestURLForOriginCheck(r *http.Request) *url.URL { + if r == nil { + return nil + } + scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if scheme == "" { + if r.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + scheme = strings.ToLower(strings.TrimSpace(strings.Split(scheme, ",")[0])) + switch scheme { + case "http", "https": + default: + return nil + } + host := strings.TrimSpace(r.Host) + if host == "" { + return nil + } + return &url.URL{Scheme: scheme, Host: host} +} + +func sameOrigin(a *url.URL, b *url.URL) bool { + if a == nil || b == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(a.Scheme), strings.TrimSpace(b.Scheme)) { + return false + } + if !strings.EqualFold(strings.TrimSpace(a.Hostname()), strings.TrimSpace(b.Hostname())) { + return false + } + return originPort(a) == originPort(b) +} + +func originPort(u *url.URL) string { + if u == nil { + return "" + } + if port := strings.TrimSpace(u.Port()); port != "" { + return port + } + switch strings.ToLower(strings.TrimSpace(u.Scheme)) { + case "http", "ws": + return "80" + case "https", "wss": + return "443" + default: + return "" + } +} + +func isLoopbackHost(host string) bool { + host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]") + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/crates/agent-gateway/internal/server/websocket_connection_state.go b/crates/agent-gateway/internal/protocol/shared/terminal_interest.go similarity index 56% rename from crates/agent-gateway/internal/server/websocket_connection_state.go rename to crates/agent-gateway/internal/protocol/shared/terminal_interest.go index 19a701b32..f4b6f5f9e 100644 --- a/crates/agent-gateway/internal/server/websocket_connection_state.go +++ b/crates/agent-gateway/internal/protocol/shared/terminal_interest.go @@ -1,4 +1,5 @@ -package server +// Package shared 存放 v1/v2 协议层共用、且不属于 session 或 wscore 的连接级构件。 +package shared import ( "strings" @@ -7,20 +8,24 @@ import ( gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) -type websocketTerminalInterestTracker struct { +// TerminalInterestTracker 记录单条连接的终端会话/项目关注集,决定事件是否转发: +// 元数据事件广播,原始输出仅推给显式附着的连接。自 v1 平移,行为不变;并发安全。 +type TerminalInterestTracker struct { mu sync.RWMutex projects map[string]struct{} sessions map[string]struct{} } -func newWebsocketTerminalInterestTracker() *websocketTerminalInterestTracker { - return &websocketTerminalInterestTracker{ +// NewTerminalInterestTracker 构造空关注集。 +func NewTerminalInterestTracker() *TerminalInterestTracker { + return &TerminalInterestTracker{ projects: make(map[string]struct{}), sessions: make(map[string]struct{}), } } -func (t *websocketTerminalInterestTracker) rememberProject(projectPathKey string) { +// RememberProject 登记对某项目终端列表的关注。 +func (t *TerminalInterestTracker) RememberProject(projectPathKey string) { projectPathKey = strings.TrimSpace(projectPathKey) if projectPathKey == "" { return @@ -30,7 +35,8 @@ func (t *websocketTerminalInterestTracker) rememberProject(projectPathKey string t.mu.Unlock() } -func (t *websocketTerminalInterestTracker) rememberSession(sessionID string, projectPathKey string) { +// RememberSession 登记对某终端会话(及其项目)的附着。 +func (t *TerminalInterestTracker) RememberSession(sessionID string, projectPathKey string) { sessionID = strings.TrimSpace(sessionID) projectPathKey = strings.TrimSpace(projectPathKey) if sessionID == "" && projectPathKey == "" { @@ -46,7 +52,8 @@ func (t *websocketTerminalInterestTracker) rememberSession(sessionID string, pro t.mu.Unlock() } -func (t *websocketTerminalInterestTracker) forget(sessionID string, projectPathKey string) { +// Forget 解除会话附着;仅给出项目键时解除项目关注。 +func (t *TerminalInterestTracker) Forget(sessionID string, projectPathKey string) { sessionID = strings.TrimSpace(sessionID) projectPathKey = strings.TrimSpace(projectPathKey) t.mu.Lock() @@ -59,7 +66,8 @@ func (t *websocketTerminalInterestTracker) forget(sessionID string, projectPathK t.mu.Unlock() } -func (t *websocketTerminalInterestTracker) shouldForward(event *gatewayv1.TerminalEvent) bool { +// ShouldForward 判定终端事件是否应推送给本连接。 +func (t *TerminalInterestTracker) ShouldForward(event *gatewayv1.TerminalEvent) bool { if event == nil { return false } @@ -67,8 +75,7 @@ func (t *websocketTerminalInterestTracker) shouldForward(event *gatewayv1.Termin projectPathKey := strings.TrimSpace(event.GetProjectPathKey()) kind := strings.TrimSpace(event.GetKind()) - // Terminal metadata changes are broadcast so each browser tab can keep its - // project list fresh; raw output remains gated behind explicit attachment. + // 元数据变化广播给所有标签页保持列表新鲜;原始输出只推给显式附着的连接。 if kind != "output" { return sessionID != "" || projectPathKey != "" } diff --git a/crates/agent-gateway/internal/protocol/shared/terminal_interest_test.go b/crates/agent-gateway/internal/protocol/shared/terminal_interest_test.go new file mode 100644 index 000000000..0e40e2bc0 --- /dev/null +++ b/crates/agent-gateway/internal/protocol/shared/terminal_interest_test.go @@ -0,0 +1,40 @@ +package shared + +import ( + "testing" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func TestTerminalInterestTrackerFiltersOutputBySession(t *testing.T) { + t.Parallel() + + tracker := NewTerminalInterestTracker() + outputEvent := &gatewayv1.TerminalEvent{ + Kind: "output", + SessionId: "session-1", + ProjectPathKey: "project-1", + } + metadataEvent := &gatewayv1.TerminalEvent{ + Kind: "created", + SessionId: "session-1", + ProjectPathKey: "project-1", + } + + if tracker.ShouldForward(outputEvent) { + t.Fatal("output should not forward before a session is attached") + } + if !tracker.ShouldForward(metadataEvent) { + t.Fatal("metadata should forward so project/session lists stay fresh") + } + + tracker.RememberSession("session-1", "project-1") + if !tracker.ShouldForward(outputEvent) { + t.Fatal("output should forward after attaching the session") + } + + tracker.Forget("session-1", "project-1") + if tracker.ShouldForward(outputEvent) { + t.Fatal("output should stop forwarding after detaching the session") + } +} diff --git a/crates/agent-gateway/internal/protocol/shared/terminal_relay.go b/crates/agent-gateway/internal/protocol/shared/terminal_relay.go new file mode 100644 index 000000000..a00e6c7fa --- /dev/null +++ b/crates/agent-gateway/internal/protocol/shared/terminal_relay.go @@ -0,0 +1,193 @@ +package shared + +import ( + "strings" + + "google.golang.org/protobuf/proto" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/session" +) + +// 终端转发域逻辑(权限门控、列表合并/过滤、兴趣登记):自 v1 处理器平移、行为不变, +// v1/v2 共用以免各自复制一份门控规则。 + +// TerminalFeaturesEnabled 判断任一 Web 终端功能是否开启。 +func TerminalFeaturesEnabled(sm *session.Manager) bool { + return sm.WebTerminalEnabled() || sm.WebSshTerminalEnabled() +} + +// TerminalSessionAllowed 按会话类型(local/ssh)检查其对 Web 端是否可见。 +func TerminalSessionAllowed(sm *session.Manager, ts *gatewayv1.TerminalSession) bool { + if ts == nil { + return false + } + if TerminalSessionKindOf(ts) == "ssh" { + return sm.WebSshTerminalEnabled() + } + return sm.WebTerminalEnabled() +} + +// TerminalSessionKindOf 归一化会话类型(空值按 local 处理)。 +func TerminalSessionKindOf(ts *gatewayv1.TerminalSession) string { + if strings.TrimSpace(ts.GetKind()) == "ssh" { + return "ssh" + } + return "local" +} + +// TerminalEventAllowed 判断终端事件是否允许推送给 Web 端。 +func TerminalEventAllowed(sm *session.Manager, event *gatewayv1.TerminalEvent) bool { + if event == nil { + return false + } + if strings.TrimSpace(event.GetKind()) == "ssh_tabs_updated" { + return sm.WebSshTerminalEnabled() + } + if ts := event.GetSession(); ts != nil { + return TerminalSessionAllowed(sm, ts) + } + sessionID := strings.TrimSpace(event.GetSessionId()) + if sessionID != "" && sm.TerminalSessionKind(sessionID) == "ssh" { + return sm.WebSshTerminalEnabled() + } + return sm.WebTerminalEnabled() +} + +// TerminalRequestAllowed 按动作与目标会话类型做权限门控。 +func TerminalRequestAllowed(sm *session.Manager, action string, sessionID string) bool { + switch action { + case "create_ssh", "answer_ssh_prompt", "cancel_ssh_prompt", "ssh_latency", + "ssh_tabs_list", "ssh_tab_open", "ssh_tab_close": + return sm.WebSshTerminalEnabled() + case "list", "close_project": + return sm.WebTerminalEnabled() || sm.WebSshTerminalEnabled() + case "rename", "close": + if sm.TerminalSessionKind(sessionID) == "ssh" { + return sm.WebSshTerminalEnabled() + } + return sm.WebTerminalEnabled() + default: + return sm.WebTerminalEnabled() + } +} + +// TerminalPermissionError 返回动作被拒时的用户可读错误信息。 +func TerminalPermissionError(action string) string { + switch action { + case "create_ssh", "answer_ssh_prompt", "cancel_ssh_prompt", "ssh_latency", + "ssh_tabs_list", "ssh_tab_open", "ssh_tab_close": + return "web SSH terminal is disabled in desktop Remote settings" + default: + return "web terminal is disabled in desktop Remote settings" + } +} + +// FinalizeTerminalResponse 统一后处理:list 结果与缓存快照合并、快照回写、按权限过滤并登记项目兴趣。 +func FinalizeTerminalResponse( + sm *session.Manager, + tracker *TerminalInterestTracker, + action string, + projectPathKey string, + resp *gatewayv1.TerminalResponse, +) *gatewayv1.TerminalResponse { + resp = MergeTerminalListWithCachedSnapshot(sm, action, projectPathKey, resp) + sm.ApplyTerminalResponseSnapshot(action, projectPathKey, resp) + resp = FilterTerminalResponseForPermissions(sm, action, resp) + RememberTerminalInterest(tracker, action, projectPathKey, resp) + return resp +} + +// MergeTerminalListWithCachedSnapshot 把桌面端 list 响应缺失、网关缓存尚存的会话并入结果 +// (桌面端重连早期列表可能不全)。 +func MergeTerminalListWithCachedSnapshot( + sm *session.Manager, + action string, + projectPathKey string, + resp *gatewayv1.TerminalResponse, +) *gatewayv1.TerminalResponse { + if resp == nil || strings.TrimSpace(action) != "list" { + return resp + } + cachedSessions := sm.TerminalSessionSnapshot(projectPathKey) + if len(cachedSessions) == 0 { + return resp + } + seen := make(map[string]struct{}, len(resp.GetSessions())) + for _, ts := range resp.GetSessions() { + id := strings.TrimSpace(ts.GetId()) + if id != "" { + seen[id] = struct{}{} + } + } + merged := make([]*gatewayv1.TerminalSession, 0, len(resp.GetSessions())+len(cachedSessions)) + merged = append(merged, resp.GetSessions()...) + changed := false + for _, ts := range cachedSessions { + id := strings.TrimSpace(ts.GetId()) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + merged = append(merged, ts) + changed = true + } + if !changed { + return resp + } + clone := proto.CloneOf(resp) + clone.Sessions = merged + return clone +} + +// FilterTerminalResponseForPermissions 过滤掉 Web 端无权看到的会话。 +func FilterTerminalResponseForPermissions( + sm *session.Manager, + action string, + resp *gatewayv1.TerminalResponse, +) *gatewayv1.TerminalResponse { + if resp == nil || action != "list" { + return resp + } + filtered := make([]*gatewayv1.TerminalSession, 0, len(resp.GetSessions())) + changed := false + for _, ts := range resp.GetSessions() { + if TerminalSessionAllowed(sm, ts) { + filtered = append(filtered, ts) + } else { + changed = true + } + } + if !changed { + return resp + } + clone := proto.CloneOf(resp) + clone.Sessions = filtered + return clone +} + +// RememberTerminalInterest 在列表/创建类动作后登记项目兴趣,供终端事件过滤使用。 +func RememberTerminalInterest( + tracker *TerminalInterestTracker, + action string, + projectPathKey string, + resp *gatewayv1.TerminalResponse, +) { + if tracker == nil { + return + } + projectPathKey = strings.TrimSpace(projectPathKey) + if respSession := resp.GetSession(); respSession != nil { + if projectPathKey == "" { + projectPathKey = strings.TrimSpace(respSession.GetProjectPathKey()) + } + } + + switch action { + case "list", "create", "create_ssh", "answer_ssh_prompt", "close_project": + tracker.RememberProject(projectPathKey) + } +} diff --git a/crates/agent-gateway/internal/server/http_origin.go b/crates/agent-gateway/internal/server/http_origin.go index 5c4fc9320..7452353f2 100644 --- a/crates/agent-gateway/internal/server/http_origin.go +++ b/crates/agent-gateway/internal/server/http_origin.go @@ -1,96 +1,13 @@ package server import ( - "net" "net/http" - "net/url" - "strings" + + "github.com/liveagent/agent-gateway/internal/protocol/shared" ) +// originAllowed delegates to the shared origin check (moved to +// internal/protocol/shared so the v2 protocol endpoints reuse it). func originAllowed(r *http.Request) bool { - origin := strings.TrimSpace(r.Header.Get("Origin")) - if origin == "" { - return true - } - parsed, err := url.Parse(origin) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return false - } - requestURL := requestURLForOriginCheck(r) - if requestURL == nil { - return false - } - if sameOrigin(parsed, requestURL) { - return true - } - originHost := strings.TrimSpace(parsed.Hostname()) - requestHost := strings.TrimSpace(requestURL.Hostname()) - if originHost == "" || requestHost == "" { - return false - } - return isLoopbackHost(originHost) && isLoopbackHost(requestHost) -} - -func requestURLForOriginCheck(r *http.Request) *url.URL { - if r == nil { - return nil - } - scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) - if scheme == "" { - if r.TLS != nil { - scheme = "https" - } else { - scheme = "http" - } - } - scheme = strings.ToLower(strings.TrimSpace(strings.Split(scheme, ",")[0])) - switch scheme { - case "http", "https": - default: - return nil - } - host := strings.TrimSpace(r.Host) - if host == "" { - return nil - } - return &url.URL{Scheme: scheme, Host: host} -} - -func sameOrigin(a *url.URL, b *url.URL) bool { - if a == nil || b == nil { - return false - } - if !strings.EqualFold(strings.TrimSpace(a.Scheme), strings.TrimSpace(b.Scheme)) { - return false - } - if !strings.EqualFold(strings.TrimSpace(a.Hostname()), strings.TrimSpace(b.Hostname())) { - return false - } - return originPort(a) == originPort(b) -} - -func originPort(u *url.URL) string { - if u == nil { - return "" - } - if port := strings.TrimSpace(u.Port()); port != "" { - return port - } - switch strings.ToLower(strings.TrimSpace(u.Scheme)) { - case "http", "ws": - return "80" - case "https", "wss": - return "443" - default: - return "" - } -} - -func isLoopbackHost(host string) bool { - host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]") - if host == "localhost" { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() + return shared.OriginAllowed(r) } diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index cb7a59e2d..93ba87c42 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -5,27 +5,35 @@ import ( "encoding/json" "errors" "io" - "log" + "log/slog" "net/http" "strings" "sync" - "sync/atomic" "time" "github.com/gorilla/websocket" "github.com/liveagent/agent-gateway/internal/auth" "github.com/liveagent/agent-gateway/internal/config" + "github.com/liveagent/agent-gateway/internal/observability" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/protocol/shared" "github.com/liveagent/agent-gateway/internal/session" + "github.com/liveagent/agent-gateway/internal/transport/wscore" ) +// websocketRequest is the inbound envelope of the v1 JSON protocol. +// +// Deprecated: v1 JSON 协议信封,请改用 v2(WebSocket+Protobuf,internal/protocol/pbws);v1 仅为旧客户端保留,流量归零后整体删除。 type websocketRequest struct { ID string `json:"id"` Type string `json:"type"` Payload json.RawMessage `json:"payload,omitempty"` } +// websocketEnvelope is the outbound envelope of the v1 JSON protocol. +// +// Deprecated: v1 JSON 协议信封,随 v1 一并移除(见 websocketRequest)。 type websocketEnvelope struct { ID string `json:"id,omitempty"` Type string `json:"type"` @@ -97,34 +105,25 @@ type websocketGitRequestPayload struct { } const ( - websocketWriteQueueDefault = 512 - websocketControlQueueSize = 64 - websocketMaxWriteRetries = 2 - websocketRetryBackoff = 100 * time.Millisecond - websocketHeartbeatGraceFloor = 5 * time.Second + websocketControlQueueSize = wscore.DefaultCtrlQueueSize ) +// websocketConnection is one browser connection speaking the v1 JSON +// protocol. +// +// Deprecated: v1 JSON 协议实现,v2 对应实现为 internal/protocol/pbws.browserConn;v1 流量归零后整体删除。 type websocketConnection struct { cfg *config.Config sm *session.Manager - conn *websocket.Conn - req *http.Request - writeMu sync.Mutex - writeTimeout time.Duration - outbox chan websocketEnvelope - // ctrlOutbox carries keep-alive and recovery envelopes past a congested - // data queue so a slow reader can be shed per-stream instead of losing - // the whole connection. - ctrlOutbox chan websocketEnvelope - droppedFrames atomic.Int64 - - closeOnce sync.Once - done chan struct{} - authorized bool + conn *websocket.Conn + req *http.Request + + // core 承载传输运行时(写泵/双队列/掉帧/心跳/空闲驱逐),v1 层只做 JSON 信封编解码与分发;done 是 core.Done() 的只读别名,供各转发器 select。 + core *wscore.Conn + done <-chan struct{} - lastInboundAt time.Time - lastInboundMu sync.Mutex + authorized bool historyEvents <-chan *gatewayv1.HistorySyncEvent historyEventsCleanup func() @@ -146,9 +145,7 @@ type websocketConnection struct { managedProcessEvents <-chan *gatewayv1.ManagedProcessSnapshot managedProcessEventsCleanup func() - heartbeatOnce sync.Once - - terminalInterest *websocketTerminalInterestTracker + terminalInterest *shared.TerminalInterestTracker chatStreamsMu sync.Mutex chatStreams map[string]*chatStreamSubscription @@ -161,6 +158,9 @@ const maxHistoryListLimit = 200 const defaultHistoryListPage = 1 const defaultHistoryListPageSize = 80 +// NewWebSocketServer serves the v1 JSON protocol on /ws. +// +// Deprecated: v1 JSON 协议入口,仅服务未升级的旧客户端(如未刷新的浏览器标签页);新客户端一律走 /ws/v2(internal/protocol/pbws.Server)。 func NewWebSocketServer(cfg *config.Config, sm *session.Manager) http.Handler { upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { @@ -174,31 +174,37 @@ func NewWebSocketServer(cfg *config.Config, sm *session.Manager) http.Handler { } conn.SetReadLimit(webSocketReadLimit(cfg)) - queueSize := websocketWriteQueueDefault - if cfg.WebSocketWriteQueueSize > 0 { - queueSize = cfg.WebSocketWriteQueueSize - } state := &websocketConnection{ cfg: cfg, sm: sm, conn: conn, req: r, - writeTimeout: cfg.WebSocketWriteTimeout, - outbox: make(chan websocketEnvelope, queueSize), - ctrlOutbox: make(chan websocketEnvelope, websocketControlQueueSize), - done: make(chan struct{}), - terminalInterest: newWebsocketTerminalInterestTracker(), - } + terminalInterest: shared.NewTerminalInterestTracker(), + } + state.core = wscore.NewConn(conn, wscore.Config{ + WriteTimeout: cfg.WebSocketWriteTimeout, + QueueSize: cfg.WebSocketWriteQueueSize, + CtrlQueueSize: websocketControlQueueSize, + HeartbeatPeriod: cfg.WebSocketHeartbeatPeriod, + HeartbeatGrace: cfg.WebSocketHeartbeatGrace, + Remote: r.RemoteAddr, + OnClose: state.releaseSubscriptions, + }) + state.done = state.core.Done() // Protocol-level pongs are produced by the browser's network stack // even while the page's JS is throttled or frozen in a hidden tab, so // they are the liveness signal that must count as inbound activity. conn.SetPongHandler(func(string) error { - state.touchInboundActivity() + state.core.TouchInboundActivity() return nil }) - _ = conn.SetReadDeadline(time.Now().Add(state.idleTimeout())) + _ = conn.SetReadDeadline(time.Now().Add(state.core.IdleTimeout())) defer state.close() state.serve() + // authorized 只在读循环内写、serve 返回后同 goroutine 读,无竞争;计数与 handleAuth 的 Active+1 配对。 + if state.authorized { + observability.Usage.V1WSConnectionsActive.Add(-1) + } }) } @@ -221,7 +227,7 @@ func (c *websocketConnection) serve() { // Any inbound frame proves the client is alive — heartbeat pongs are // not the only liveness evidence. - c.touchInboundActivity() + c.core.TouchInboundActivity() req.ID = strings.TrimSpace(req.ID) req.Type = strings.TrimSpace(req.Type) @@ -273,50 +279,49 @@ func (c *websocketConnection) serve() { } func (c *websocketConnection) close() { - c.closeOnce.Do(func() { - close(c.done) - if c.historyEventsCleanup != nil { - c.historyEventsCleanup() - c.historyEventsCleanup = nil - } - if c.settingsEventsCleanup != nil { - c.settingsEventsCleanup() - c.settingsEventsCleanup = nil - } - if c.terminalEventsCleanup != nil { - c.terminalEventsCleanup() - c.terminalEventsCleanup = nil - } - if c.sftpEventsCleanup != nil { - c.sftpEventsCleanup() - c.sftpEventsCleanup = nil - } - if c.chatQueueEventsCleanup != nil { - c.chatQueueEventsCleanup() - c.chatQueueEventsCleanup = nil - } - if c.chatActivityEventsCleanup != nil { - c.chatActivityEventsCleanup() - c.chatActivityEventsCleanup = nil - } - if c.tunnelStateEventsCleanup != nil { - c.tunnelStateEventsCleanup() - c.tunnelStateEventsCleanup = nil - } - if c.statusEventsCleanup != nil { - c.statusEventsCleanup() - c.statusEventsCleanup = nil - } - if c.managedProcessEventsCleanup != nil { - c.managedProcessEventsCleanup() - c.managedProcessEventsCleanup = nil - } - c.cleanupChatStreamSubscriptions() - c.cleanupWorkspaceSubscriptions() - if c.conn != nil { - _ = c.conn.Close() - } - }) + c.core.Close() +} + +// releaseSubscriptions 由 core 关闭时回调(恰好一次),释放本连接持有的全部 session 层订阅与 chat/workspace 流。 +func (c *websocketConnection) releaseSubscriptions() { + if c.historyEventsCleanup != nil { + c.historyEventsCleanup() + c.historyEventsCleanup = nil + } + if c.settingsEventsCleanup != nil { + c.settingsEventsCleanup() + c.settingsEventsCleanup = nil + } + if c.terminalEventsCleanup != nil { + c.terminalEventsCleanup() + c.terminalEventsCleanup = nil + } + if c.sftpEventsCleanup != nil { + c.sftpEventsCleanup() + c.sftpEventsCleanup = nil + } + if c.chatQueueEventsCleanup != nil { + c.chatQueueEventsCleanup() + c.chatQueueEventsCleanup = nil + } + if c.chatActivityEventsCleanup != nil { + c.chatActivityEventsCleanup() + c.chatActivityEventsCleanup = nil + } + if c.tunnelStateEventsCleanup != nil { + c.tunnelStateEventsCleanup() + c.tunnelStateEventsCleanup = nil + } + if c.statusEventsCleanup != nil { + c.statusEventsCleanup() + c.statusEventsCleanup = nil + } + if c.managedProcessEventsCleanup != nil { + c.managedProcessEventsCleanup() + c.managedProcessEventsCleanup = nil + } + c.cleanupChatStreamSubscriptions() + c.cleanupWorkspaceSubscriptions() } func (c *websocketConnection) handleAuth(req websocketRequest) { @@ -334,10 +339,17 @@ func (c *websocketConnection) handleAuth(req websocketRequest) { } c.authorized = true + // v1 使用打点:观察弃用链路流量归零后即可在后续版本删除 v1。 + observability.Usage.V1WSConnectionsTotal.Add(1) + observability.Usage.V1WSConnectionsActive.Add(1) + slog.Warn("deprecated v1 websocket connection established", + "remote", c.req.RemoteAddr, + ) + c.core.SetAuthorized() // The pre-auth deadline was deliberately left un-refreshed; re-arm it now // so a slow-to-auth client does not die moments after succeeding. - c.touchInboundActivity() - go c.writeLoop() + c.core.TouchInboundActivity() + c.core.StartWriteLoop() c.startHistorySyncForwarder() c.startSettingsSyncForwarder() c.startTerminalEventForwarder() @@ -347,7 +359,7 @@ func (c *websocketConnection) handleAuth(req websocketRequest) { c.startTunnelStateForwarder() c.startManagedProcessStateForwarder() c.startStatusEventForwarder() - c.startWebSocketHeartbeat() + c.core.StartHeartbeat(c.buildHeartbeatPing) if err := c.writeResponse(req.ID, map[string]any{"ok": true}); err != nil { c.close() return @@ -599,100 +611,31 @@ func (c *websocketConnection) replayTerminalSessionSnapshot() { } } -func (c *websocketConnection) rememberTerminalProject(projectPathKey string) { - c.terminalInterest.rememberProject(projectPathKey) -} - -func (c *websocketConnection) rememberTerminalSession(sessionID string, projectPathKey string) { - c.terminalInterest.rememberSession(sessionID, projectPathKey) -} - -func (c *websocketConnection) forgetTerminalInterest(sessionID string, projectPathKey string) { - c.terminalInterest.forget(sessionID, projectPathKey) -} - func (c *websocketConnection) shouldForwardTerminalEvent(event *gatewayv1.TerminalEvent) bool { - return c.terminalEventAllowed(event) && c.terminalInterest.shouldForward(event) + return c.terminalEventAllowed(event) && c.terminalInterest.ShouldForward(event) } -func (c *websocketConnection) startWebSocketHeartbeat() { - c.heartbeatOnce.Do(func() { - period := c.cfg.WebSocketHeartbeatPeriod - if period <= 0 { - period = 15 * time.Second - } - go func() { - ticker := time.NewTicker(period) - defer ticker.Stop() - for { - select { - case <-c.done: - return - case <-ticker.C: - c.lastInboundMu.Lock() - lastInbound := c.lastInboundAt - c.lastInboundMu.Unlock() - if time.Since(lastInbound) > c.idleTimeout() { - c.close() - return - } - // Protocol ping first: browsers answer it from the network - // process with no JS involvement, so a throttled or frozen - // tab keeps proving liveness. WriteControl is documented - // safe concurrently with the write loop. - deadline := time.Now().Add(c.controlWriteTimeout()) - _ = c.conn.WriteControl(websocket.PingMessage, nil, deadline) - // The JSON ping stays for the page's benefit: it is the - // only inbound activity the client's JS can observe, and - // its stall heuristics depend on it. Best-effort — the - // idle check above is the sole eviction authority. - _ = c.writeEnvelope(websocketEnvelope{ - Type: "ping", - Payload: map[string]any{ - "timestamp": time.Now().Unix(), - }, - }) - } - } - }() +// buildHeartbeatPing 为 wscore 心跳循环构造 v1 JSON ping 信封帧。 +func (c *websocketConnection) buildHeartbeatPing() (wscore.Frame, bool) { + data, err := json.Marshal(websocketEnvelope{ + Type: "ping", + Payload: map[string]any{ + "timestamp": time.Now().Unix(), + }, }) -} - -// touchInboundActivity records liveness for every inbound frame and pushes the -// read deadline forward accordingly. Pre-auth the initial deadline stands -// un-refreshed: whatever a client sends, it gets one idleTimeout window to -// authenticate. (authorized is only written on the read loop, and both this -// function's callers — the read loop and gorilla's pong handler — run there.) -func (c *websocketConnection) touchInboundActivity() { - c.lastInboundMu.Lock() - c.lastInboundAt = time.Now() - c.lastInboundMu.Unlock() - if !c.authorized { - return - } - _ = c.conn.SetReadDeadline(time.Now().Add(c.idleTimeout())) -} - -func (c *websocketConnection) idleTimeout() time.Duration { - period := c.cfg.WebSocketHeartbeatPeriod - if period <= 0 { - period = 15 * time.Second - } - grace := c.cfg.WebSocketHeartbeatGrace - if grace <= 0 { - grace = websocketHeartbeatGraceFloor - } - return period*3 + grace -} - -func (c *websocketConnection) controlWriteTimeout() time.Duration { - if c.writeTimeout > 0 { - return c.writeTimeout + if err != nil { + return wscore.Frame{}, false } - return 10 * time.Second + return wscore.Frame{ + Class: wscore.FramePing, + Kind: "ping", + MessageType: websocket.TextMessage, + Data: data, + }, true } func (c *websocketConnection) dispatch(req websocketRequest) { + observability.Usage.V1WSRequestsTotal.Add(1) handler := websocketRequestHandlers[req.Type] if handler == nil { _ = c.writeError(req.ID, "unsupported request type") @@ -719,25 +662,6 @@ 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) writeResponse(requestID string, payload any) error { return c.writeEnvelope(websocketEnvelope{ ID: requestID, @@ -770,7 +694,8 @@ func (c *websocketConnection) writeEvent(eventType string, payload any) error { }) } -var errWriteQueueFull = errors.New("write queue full") +// errWriteQueueFull 是 wscore 掉帧错误的包内别名,保持既有 errors.Is 判定。 +var errWriteQueueFull = wscore.ErrWriteQueueFull // isControlEnvelopeType reports envelopes that must reach the client even // when the data queue is congested: keep-alive pings and the signals a client @@ -784,167 +709,31 @@ func isControlEnvelopeType(envelopeType string) bool { } } -// writeEnvelope queues an envelope for delivery. Control envelopes ride a -// small priority queue; shed-able event/stream data reports errWriteQueueFull -// without dropping the whole link. Correlated unary responses are different: -// silently losing one leaves the browser pending until its much later timeout, -// so a persistently full data queue closes the socket and lets the client -// reconnect/retry instead. Direct-write failures likewise terminate it. -func (c *websocketConnection) writeEnvelope(envelope websocketEnvelope) error { - if envelope.priority || isControlEnvelopeType(envelope.Type) { - return c.enqueueControlEnvelope(envelope) - } - err := c.enqueueEnvelope(envelope) - if errors.Is(err, errWriteQueueFull) { - c.noteDroppedEnvelope(envelope.Type) - if envelope.Type == "response" && envelope.ID != "" { - c.close() - } - } - return err -} - -// enqueueEnvelope waits up to writeTimeout for a slot when the outbox is -// momentarily full (token bursts to a slow reader); only a persistent backlog -// reports errWriteQueueFull. The fast path allocates nothing. -func (c *websocketConnection) enqueueEnvelope(envelope websocketEnvelope) error { - select { - case <-c.done: - return errors.New("connection closed") - case c.outbox <- envelope: - return nil +// classifyEnvelope 将 v1 信封映射为 wscore 帧类别(拥塞策略),逐字保持原 writeEnvelope 的路由语义。 +func classifyEnvelope(envelope websocketEnvelope) wscore.FrameClass { + switch { + case envelope.Type == "ping": + return wscore.FramePing + case envelope.priority || isControlEnvelopeType(envelope.Type): + return wscore.FrameControl + case envelope.Type == "response" && envelope.ID != "": + return wscore.FrameResponse default: - } - - timer := time.NewTimer(c.controlWriteTimeout()) - defer timer.Stop() - select { - case <-c.done: - return errors.New("connection closed") - case c.outbox <- envelope: - return nil - case <-timer.C: - return errWriteQueueFull + return wscore.FrameData } } -func (c *websocketConnection) enqueueControlEnvelope(envelope websocketEnvelope) error { - select { - case <-c.done: - return errors.New("connection closed") - case c.ctrlOutbox <- envelope: - return nil - default: - } - - if envelope.Type == "ping" { - // Keep-alive pings are periodic; when the control queue is full the - // next tick supersedes this one. - c.noteDroppedEnvelope(envelope.Type) - return nil - } - - timer := time.NewTimer(c.controlWriteTimeout()) - defer timer.Stop() - select { - case <-c.done: - return errors.New("connection closed") - case c.ctrlOutbox <- envelope: - return nil - case <-timer.C: - c.noteDroppedEnvelope(envelope.Type) - return errWriteQueueFull - } -} - -func (c *websocketConnection) noteDroppedEnvelope(envelopeType string) { - dropped := c.droppedFrames.Add(1) - // Log the first drop and every 100th after it: enough to see shedding in - // production without a log line per frame during a burst. - if dropped == 1 || dropped%100 == 0 { - remote := "" - if c.req != nil { - remote = c.req.RemoteAddr - } - log.Printf( - "websocket: shed %q frame for slow client (dropped=%d remote=%s)", - envelopeType, - dropped, - remote, - ) - } -} - -// writeLoop drains the control queue ahead of the data queue (mirroring the -// agent link's dedicated ping channel) so congestion cannot starve keep-alive -// or stream-recovery envelopes. -func (c *websocketConnection) writeLoop() { - for { - select { - case <-c.done: - return - case envelope := <-c.ctrlOutbox: - if !c.deliverEnvelope(envelope) { - return - } - case envelope := <-c.outbox: - if !c.deliverEnvelope(envelope) { - return - } - for drained := 0; drained < 64; drained++ { - select { - case extra := <-c.ctrlOutbox: - if !c.deliverEnvelope(extra) { - return - } - continue - default: - } - select { - case extra := <-c.outbox: - if !c.deliverEnvelope(extra) { - return - } - default: - goto batchDone - } - } - batchDone: - } - } -} - -// deliverEnvelope writes one envelope with bounded retries; exhausting them -// means the socket itself is unwritable (dead link), which is the only -// congestion-adjacent condition allowed to close the connection. -func (c *websocketConnection) deliverEnvelope(envelope websocketEnvelope) bool { - if err := c.writeEnvelopeDirect(envelope); err == nil { - return true - } - for attempt := 0; attempt < websocketMaxWriteRetries; attempt++ { - select { - case <-c.done: - return false - case <-time.After(websocketRetryBackoff): - } - if err := c.writeEnvelopeDirect(envelope); err == nil { - return true - } - } - c.close() - return false -} - -func (c *websocketConnection) writeEnvelopeDirect(envelope websocketEnvelope) error { - c.writeMu.Lock() - defer c.writeMu.Unlock() - if c.writeTimeout > 0 { - if err := c.conn.SetWriteDeadline(time.Now().Add(c.writeTimeout)); err != nil { - return err - } - defer func() { - _ = c.conn.SetWriteDeadline(time.Time{}) - }() - } - return c.conn.WriteJSON(envelope) +// writeEnvelope 编码 v1 JSON 信封并交给共享写泵;拥塞策略(控制帧优先、数据掉帧、关联响应关连接)由帧类别声明、wscore 统一执行。 +func (c *websocketConnection) writeEnvelope(envelope websocketEnvelope) error { + data, err := json.Marshal(envelope) + if err != nil { + return err + } + return c.core.Enqueue(wscore.Frame{ + Class: classifyEnvelope(envelope), + RequestID: envelope.ID, + Kind: envelope.Type, + MessageType: websocket.TextMessage, + Data: data, + }) } diff --git a/crates/agent-gateway/internal/server/websocket_chat_handlers.go b/crates/agent-gateway/internal/server/websocket_chat_handlers.go index 1635ca5c8..c0d172e0a 100644 --- a/crates/agent-gateway/internal/server/websocket_chat_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_chat_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( @@ -8,6 +9,7 @@ import ( "time" "github.com/google/uuid" + "github.com/liveagent/agent-gateway/internal/chatcmd" "github.com/liveagent/agent-gateway/internal/config" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" "github.com/liveagent/agent-gateway/internal/session" @@ -187,9 +189,9 @@ func (c *websocketConnection) handleChatPrepare(req websocketRequest) { return } - ctx, cancel := context.WithTimeout(context.Background(), chatPrepareTimeout(c.cfg)) + ctx, cancel := context.WithTimeout(context.Background(), chatcmd.PrepareTimeout(c.cfg)) defer cancel() - if err := probeChatRuntime(ctx, c.sm); err != nil { + if err := chatcmd.ProbeRuntime(ctx, c.sm); err != nil { _ = c.writeError(req.ID, websocketErrorMessage(err)) return } @@ -200,7 +202,7 @@ func (c *websocketConnection) handleChatPrepare(req websocketRequest) { } func (c *websocketConnection) handleChatCommand(req websocketRequest) { - commandType, body, baseMessageRef, err := decodeChatCommandPayload(req.Payload) + commandType, body, baseMessageRef, err := chatcmd.DecodeCommandPayload(req.Payload) if err != nil { _ = c.writeError(req.ID, "invalid chat command payload") return @@ -214,7 +216,7 @@ func (c *websocketConnection) handleChatCommand(req websocketRequest) { _ = c.writeError(req.ID, "base_message_ref is required") return } - if err := validateChatMessageRef(baseMessageRef); err != nil { + if err := chatcmd.ValidateMessageRef(baseMessageRef); err != nil { _ = c.writeError(req.ID, err.Error()) return } @@ -226,7 +228,7 @@ func (c *websocketConnection) handleChatCommand(req websocketRequest) { return } - if err := normalizeChatRequestBody(&body); err != nil { + if err := chatcmd.NormalizeRequestBody(&body); err != nil { _ = c.writeError(req.ID, err.Error()) return } @@ -241,9 +243,9 @@ func (c *websocketConnection) handleChatCommand(req websocketRequest) { return } probeCtx, probeCancel := context.WithTimeout( - context.Background(), chatPrepareTimeout(c.cfg), + context.Background(), chatcmd.PrepareTimeout(c.cfg), ) - probeErr := probeChatRuntimeForCommand(probeCtx, c.sm) + probeErr := chatcmd.ProbeRuntimeForCommand(probeCtx, c.sm) probeCancel() if probeErr != nil { _ = c.writeError(req.ID, websocketErrorMessage(probeErr)) @@ -256,7 +258,7 @@ func (c *websocketConnection) handleChatCommand(req websocketRequest) { body.ConversationID, body.Workdir, body.ClientRequestID, - buildAcceptedChatCommandPayloads(body, baseMessageRef), + chatcmd.BuildAcceptedCommandPayloads(body, baseMessageRef), ) if start.Deduped { c.respondChatCommandDeduped(req.ID, start) @@ -267,8 +269,8 @@ func (c *websocketConnection) handleChatCommand(req websocketRequest) { _ = c.writeChatCommandAcceptedResponse(req.ID, start) go c.forwardChatCommandUpdates(updates, cleanupWatch) - go dispatchAcceptedChatCommand( - context.Background(), c.cfg, c.sm, cleanupWatch, start, body, baseMessageRef, newChatTraceID(), + go chatcmd.DispatchAcceptedCommand( + context.Background(), c.cfg, c.sm, cleanupWatch, start, body, baseMessageRef, chatcmd.NewTraceID(), ) } @@ -344,7 +346,7 @@ func cleanupChatCommandWatchAfter(cfg *config.Config, cleanup func()) { if cleanup == nil { return } - timeout := chatStartTimeout(cfg) + chatRenderStartTimeout(cfg) + timeout := chatcmd.StartTimeout(cfg) + chatcmd.RenderStartTimeout(cfg) if timeout <= 0 { timeout = 15 * time.Second } @@ -404,7 +406,7 @@ func (c *websocketConnection) handleChatCancel(req websocketRequest) { if err := c.sm.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ RequestId: runID, Timestamp: time.Now().Unix(), - Payload: buildChatCancelCommandPayload(payload.ConversationID), + Payload: chatcmd.BuildCancelCommandPayload(payload.ConversationID), }); err != nil { _ = c.writeError(req.ID, websocketErrorMessage(err)) return diff --git a/crates/agent-gateway/internal/server/websocket_chat_shed_test.go b/crates/agent-gateway/internal/server/websocket_chat_shed_test.go index 14511c266..d76b7dc83 100644 --- a/crates/agent-gateway/internal/server/websocket_chat_shed_test.go +++ b/crates/agent-gateway/internal/server/websocket_chat_shed_test.go @@ -7,6 +7,7 @@ import ( gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" "github.com/liveagent/agent-gateway/internal/session" + "github.com/liveagent/agent-gateway/internal/transport/wscore" ) // A congested data queue must shed the chat subscription (reset rides the @@ -18,15 +19,17 @@ func TestForwardConversationEventsShedsSubscriptionOnQueueFull(t *testing.T) { sm := session.NewManager() sm.RecordAuthentication("desktop-agent", "0.9.0", "session-shed") sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) + core := wscore.NewConn(nil, wscore.Config{ + QueueSize: 1, + WriteTimeout: 30 * time.Millisecond, + }) c := &websocketConnection{ - sm: sm, - outbox: make(chan websocketEnvelope, 1), - ctrlOutbox: make(chan websocketEnvelope, websocketControlQueueSize), - writeTimeout: 30 * time.Millisecond, - done: make(chan struct{}), + sm: sm, + core: core, + done: core.Done(), } // Congest the data queue with nothing draining it. - c.outbox <- websocketEnvelope{Type: "chat.event"} + core.Outbox <- wscore.Frame{Kind: "chat.event"} sub := sm.SubscribeConversationStream("conv-shed", 0, "") forwarderDone := make(chan struct{}) @@ -59,9 +62,9 @@ func TestForwardConversationEventsShedsSubscriptionOnQueueFull(t *testing.T) { }) select { - case env := <-c.ctrlOutbox: - if env.Type != "chat.subscription_reset" { - t.Fatalf("control envelope type = %q, want chat.subscription_reset", env.Type) + case frame := <-core.CtrlOutbox: + if frame.Kind != "chat.subscription_reset" { + t.Fatalf("control frame kind = %q, want chat.subscription_reset", frame.Kind) } case <-time.After(2 * time.Second): t.Fatal("timed out waiting for chat.subscription_reset on the control queue") diff --git a/crates/agent-gateway/internal/server/websocket_connection_state_test.go b/crates/agent-gateway/internal/server/websocket_connection_state_test.go index 6108c43e4..4c42dbd22 100644 --- a/crates/agent-gateway/internal/server/websocket_connection_state_test.go +++ b/crates/agent-gateway/internal/server/websocket_connection_state_test.go @@ -4,42 +4,10 @@ import ( "testing" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/protocol/shared" "github.com/liveagent/agent-gateway/internal/session" ) -func TestWebsocketTerminalInterestTrackerFiltersOutputBySession(t *testing.T) { - t.Parallel() - - tracker := newWebsocketTerminalInterestTracker() - outputEvent := &gatewayv1.TerminalEvent{ - Kind: "output", - SessionId: "session-1", - ProjectPathKey: "project-1", - } - metadataEvent := &gatewayv1.TerminalEvent{ - Kind: "created", - SessionId: "session-1", - ProjectPathKey: "project-1", - } - - if tracker.shouldForward(outputEvent) { - t.Fatal("output should not forward before a session is attached") - } - if !tracker.shouldForward(metadataEvent) { - t.Fatal("metadata should forward so project/session lists stay fresh") - } - - tracker.rememberSession("session-1", "project-1") - if !tracker.shouldForward(outputEvent) { - t.Fatal("output should forward after attaching the session") - } - - tracker.forget("session-1", "project-1") - if tracker.shouldForward(outputEvent) { - t.Fatal("output should stop forwarding after detaching the session") - } -} - func TestWebsocketTerminalPermissionsSeparateLocalAndSshSessions(t *testing.T) { t.Parallel() @@ -103,7 +71,7 @@ func TestWebsocketTerminalEventForwardingAllowsSshOnlyStatusEvents(t *testing.T) manager.ApplySettingsJSON(`{"remote":{"enableWebTerminal":false,"enableWebSshTerminal":true}}`) conn := &websocketConnection{ sm: manager, - terminalInterest: newWebsocketTerminalInterestTracker(), + terminalInterest: shared.NewTerminalInterestTracker(), } if !conn.shouldForwardTerminalEvent(&gatewayv1.TerminalEvent{ diff --git a/crates/agent-gateway/internal/server/websocket_history_handlers.go b/crates/agent-gateway/internal/server/websocket_history_handlers.go index 38cbe43f7..235528e17 100644 --- a/crates/agent-gateway/internal/server/websocket_history_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_history_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( @@ -5,6 +6,7 @@ import ( "strings" "time" + "github.com/liveagent/agent-gateway/internal/chatcmd" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) @@ -239,9 +241,9 @@ func (c *websocketConnection) handleHistoryGet(req websocketRequest) { func (c *websocketConnection) handleHistoryPrefix(req websocketRequest) { type payload struct { - ConversationID string `json:"conversation_id"` - MaxMessages int32 `json:"max_messages"` - BaseMessageRef *chatCommandMessageRef `json:"base_message_ref"` + ConversationID string `json:"conversation_id"` + MaxMessages int32 `json:"max_messages"` + BaseMessageRef *chatcmd.MessageRef `json:"base_message_ref"` } var body payload @@ -258,7 +260,7 @@ func (c *websocketConnection) handleHistoryPrefix(req websocketRequest) { _ = c.writeError(req.ID, "base_message_ref is required") return } - if err := validateChatMessageRef(body.BaseMessageRef); err != nil { + if err := chatcmd.ValidateMessageRef(body.BaseMessageRef); err != nil { _ = c.writeError(req.ID, err.Error()) return } @@ -270,7 +272,7 @@ func (c *websocketConnection) handleHistoryPrefix(req websocketRequest) { HistoryPrefix: &gatewayv1.HistoryPrefixRequest{ ConversationId: conversationID, MaxMessages: body.MaxMessages, - BaseMessageRef: buildProtoChatMessageRef(body.BaseMessageRef), + BaseMessageRef: chatcmd.BuildProtoMessageRef(body.BaseMessageRef), }, }, }) @@ -352,8 +354,8 @@ func (c *websocketConnection) handleHistoryRename(req websocketRequest) { func (c *websocketConnection) handleHistoryBranch(req websocketRequest) { type payload struct { - ConversationID string `json:"conversation_id"` - BaseMessageRef *chatCommandMessageRef `json:"base_message_ref"` + ConversationID string `json:"conversation_id"` + BaseMessageRef *chatcmd.MessageRef `json:"base_message_ref"` } var body payload @@ -370,7 +372,7 @@ func (c *websocketConnection) handleHistoryBranch(req websocketRequest) { _ = c.writeError(req.ID, "base_message_ref is required") return } - if err := validateChatMessageRef(body.BaseMessageRef); err != nil { + if err := chatcmd.ValidateMessageRef(body.BaseMessageRef); err != nil { _ = c.writeError(req.ID, err.Error()) return } @@ -381,7 +383,7 @@ func (c *websocketConnection) handleHistoryBranch(req websocketRequest) { Payload: &gatewayv1.GatewayEnvelope_HistoryBranch{ HistoryBranch: &gatewayv1.HistoryBranchRequest{ ConversationId: conversationID, - BaseMessageRef: buildProtoChatMessageRef(body.BaseMessageRef), + BaseMessageRef: chatcmd.BuildProtoMessageRef(body.BaseMessageRef), }, }, }) diff --git a/crates/agent-gateway/internal/server/websocket_roundtrip.go b/crates/agent-gateway/internal/server/websocket_roundtrip.go index 68f5775a1..63c2a42b7 100644 --- a/crates/agent-gateway/internal/server/websocket_roundtrip.go +++ b/crates/agent-gateway/internal/server/websocket_roundtrip.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( @@ -19,37 +20,13 @@ func decodeWebSocketPayload(raw json.RawMessage, target any) error { return decoder.Decode(target) } -func waitForAgentEnvelope( - ctx context.Context, - ch <-chan *gatewayv1.AgentEnvelope, - done <-chan struct{}, -) (*gatewayv1.AgentEnvelope, error) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-done: - return nil, session.ErrAgentOffline - case env, ok := <-ch: - if !ok { - return nil, session.ErrAgentOffline - } - return env, nil - } -} - func awaitAgentUnaryResponse( ctx context.Context, sm *session.Manager, requestID string, envelope *gatewayv1.GatewayEnvelope, ) (*gatewayv1.AgentEnvelope, error) { - ch, done, cleanup, err := sm.RegisterStreamAndSendContext(ctx, requestID, envelope) - if err != nil { - return nil, err - } - defer cleanup() - - return waitForAgentEnvelope(ctx, ch, done) + return sm.AwaitUnaryResponse(ctx, requestID, envelope) } func websocketErrorMessage(err error) string { diff --git a/crates/agent-gateway/internal/server/websocket_terminal_handlers.go b/crates/agent-gateway/internal/server/websocket_terminal_handlers.go index 9d3cb12b6..5cde7a3e4 100644 --- a/crates/agent-gateway/internal/server/websocket_terminal_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_terminal_handlers.go @@ -1,47 +1,32 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( "strings" "time" - "google.golang.org/protobuf/proto" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/protocol/shared" ) func terminalActionFromRequestType(requestType string) string { return strings.TrimPrefix(strings.TrimSpace(requestType), "terminal.") } +// Terminal gating and response post-processing live in +// internal/protocol/shared (shared with the v2 protocol); the methods below +// are thin v1 adapters. + func (c *websocketConnection) terminalFeaturesEnabled() bool { - return c.sm.WebTerminalEnabled() || c.sm.WebSshTerminalEnabled() + return shared.TerminalFeaturesEnabled(c.sm) } func (c *websocketConnection) terminalSessionAllowed(session *gatewayv1.TerminalSession) bool { - if session == nil { - return false - } - if terminalSessionKind(session) == "ssh" { - return c.sm.WebSshTerminalEnabled() - } - return c.sm.WebTerminalEnabled() + return shared.TerminalSessionAllowed(c.sm, session) } func (c *websocketConnection) terminalEventAllowed(event *gatewayv1.TerminalEvent) bool { - if event == nil { - return false - } - if strings.TrimSpace(event.GetKind()) == "ssh_tabs_updated" { - return c.sm.WebSshTerminalEnabled() - } - if session := event.GetSession(); session != nil { - return c.terminalSessionAllowed(session) - } - sessionID := strings.TrimSpace(event.GetSessionId()) - if sessionID != "" && c.sm.TerminalSessionKind(sessionID) == "ssh" { - return c.sm.WebSshTerminalEnabled() - } - return c.sm.WebTerminalEnabled() + return shared.TerminalEventAllowed(c.sm, event) } func (c *websocketConnection) handleTerminalRequest(req websocketRequest) { @@ -52,8 +37,8 @@ func (c *websocketConnection) handleTerminalRequest(req websocketRequest) { _ = c.writeError(req.ID, "invalid "+req.Type+" payload") return } - if !c.terminalRequestAllowed(action, body) { - _ = c.writeError(req.ID, terminalPermissionError(action)) + if !shared.TerminalRequestAllowed(c.sm, action, strings.TrimSpace(body.SessionID)) { + _ = c.writeError(req.ID, shared.TerminalPermissionError(action)) return } @@ -113,120 +98,7 @@ func (c *websocketConnection) handleTerminalRequest(req websocketRequest) { _ = c.writeError(req.ID, "unexpected agent response") return } - resp = c.mergeTerminalListWithCachedSnapshot(action, projectPathKey, resp) - c.sm.ApplyTerminalResponseSnapshot(action, projectPathKey, resp) - filteredResp := c.filterTerminalResponseForPermissions(action, resp) - c.rememberTerminalInterest(action, body, filteredResp) + filteredResp := shared.FinalizeTerminalResponse(c.sm, c.terminalInterest, action, projectPathKey, resp) _ = c.writeResponse(req.ID, websocketTerminalResponsePayload(filteredResp)) } - -func (c *websocketConnection) mergeTerminalListWithCachedSnapshot( - action string, - projectPathKey string, - resp *gatewayv1.TerminalResponse, -) *gatewayv1.TerminalResponse { - if resp == nil || strings.TrimSpace(action) != "list" { - return resp - } - cachedSessions := c.sm.TerminalSessionSnapshot(projectPathKey) - if len(cachedSessions) == 0 { - return resp - } - seen := make(map[string]struct{}, len(resp.GetSessions())) - for _, session := range resp.GetSessions() { - id := strings.TrimSpace(session.GetId()) - if id != "" { - seen[id] = struct{}{} - } - } - merged := make([]*gatewayv1.TerminalSession, 0, len(resp.GetSessions())+len(cachedSessions)) - merged = append(merged, resp.GetSessions()...) - changed := false - for _, session := range cachedSessions { - id := strings.TrimSpace(session.GetId()) - if id == "" { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - merged = append(merged, session) - changed = true - } - if !changed { - return resp - } - clone := proto.CloneOf(resp) - clone.Sessions = merged - return clone -} - -func (c *websocketConnection) filterTerminalResponseForPermissions(action string, resp *gatewayv1.TerminalResponse) *gatewayv1.TerminalResponse { - if resp == nil || action != "list" { - return resp - } - filtered := make([]*gatewayv1.TerminalSession, 0, len(resp.GetSessions())) - changed := false - for _, session := range resp.GetSessions() { - if c.terminalSessionAllowed(session) { - filtered = append(filtered, session) - } else { - changed = true - } - } - if !changed { - return resp - } - clone := proto.CloneOf(resp) - clone.Sessions = filtered - return clone -} - -func (c *websocketConnection) rememberTerminalInterest(action string, body websocketTerminalRequestPayload, resp *gatewayv1.TerminalResponse) { - projectPathKey := strings.TrimSpace(body.ProjectPathKey) - sessionID := strings.TrimSpace(body.SessionID) - if respSession := resp.GetSession(); respSession != nil { - if projectPathKey == "" { - projectPathKey = strings.TrimSpace(respSession.GetProjectPathKey()) - } - if sessionID == "" { - sessionID = strings.TrimSpace(respSession.GetId()) - } - } - - switch action { - case "list", "create", "create_ssh", "answer_ssh_prompt", "close_project": - c.rememberTerminalProject(projectPathKey) - } -} - -func (c *websocketConnection) terminalRequestAllowed(action string, body websocketTerminalRequestPayload) bool { - switch action { - case "create_ssh", "answer_ssh_prompt", "cancel_ssh_prompt", "ssh_latency", - "ssh_tabs_list", "ssh_tab_open", "ssh_tab_close": - return c.sm.WebSshTerminalEnabled() - case "list": - return c.sm.WebTerminalEnabled() || c.sm.WebSshTerminalEnabled() - case "close_project": - return c.sm.WebTerminalEnabled() || c.sm.WebSshTerminalEnabled() - case "rename", "close": - if c.sm.TerminalSessionKind(body.SessionID) == "ssh" { - return c.sm.WebSshTerminalEnabled() - } - return c.sm.WebTerminalEnabled() - default: - return c.sm.WebTerminalEnabled() - } -} - -func terminalPermissionError(action string) string { - switch action { - case "create_ssh", "answer_ssh_prompt", "cancel_ssh_prompt", "ssh_latency", - "ssh_tabs_list", "ssh_tab_open", "ssh_tab_close": - return "web SSH terminal is disabled in desktop Remote settings" - default: - return "web terminal is disabled in desktop Remote settings" - } -} diff --git a/crates/agent-gateway/internal/server/websocket_terminal_stream.go b/crates/agent-gateway/internal/server/websocket_terminal_stream.go index b7976d165..62c2eb397 100644 --- a/crates/agent-gateway/internal/server/websocket_terminal_stream.go +++ b/crates/agent-gateway/internal/server/websocket_terminal_stream.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "encoding/json" "errors" + "log/slog" "net/http" "strings" "sync" @@ -13,7 +14,9 @@ import ( "github.com/gorilla/websocket" "github.com/liveagent/agent-gateway/internal/auth" "github.com/liveagent/agent-gateway/internal/config" + "github.com/liveagent/agent-gateway/internal/observability" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/protocol/shared" "github.com/liveagent/agent-gateway/internal/session" ) @@ -67,6 +70,10 @@ type terminalStreamWSConnection struct { streams map[string]struct{} } +// NewTerminalWebSocketServer serves the v1 custom binary terminal stream on +// /ws/terminal. +// +// Deprecated: v1 自定义二进制终端流([ver][kind][len][JSON 头][数据])已被 v2 /ws/v2/terminal(proto TerminalStreamFrame 帧)取代,仅为旧客户端保留;流量归零后删除。 func NewTerminalWebSocketServer(cfg *config.Config, sm *session.Manager) http.Handler { upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { @@ -135,6 +142,8 @@ func (c *terminalStreamWSConnection) authenticate() bool { _ = c.conn.WriteJSON(map[string]any{"type": "error", "error": "unauthorized"}) return false } + observability.Usage.V1TerminalWSConnectionsTotal.Add(1) + slog.Warn("deprecated v1 terminal websocket connection established") _ = c.conn.WriteJSON(map[string]any{"type": "ready"}) return true } @@ -146,7 +155,7 @@ func (c *terminalStreamWSConnection) handleFrame(frame *gatewayv1.TerminalStream frame.GetStreamId(), frame.GetSessionId(), frame.GetProjectPathKey(), - terminalPermissionError(kind), + shared.TerminalPermissionError(kind), )) return } diff --git a/crates/agent-gateway/internal/server/websocket_write_test.go b/crates/agent-gateway/internal/server/websocket_write_test.go index 963956fd1..f2443230e 100644 --- a/crates/agent-gateway/internal/server/websocket_write_test.go +++ b/crates/agent-gateway/internal/server/websocket_write_test.go @@ -1,66 +1,45 @@ package server import ( + "encoding/json" "errors" "testing" "time" -) - -func newEnqueueTestConnection(outboxSize int, writeTimeout time.Duration) *websocketConnection { - return &websocketConnection{ - outbox: make(chan websocketEnvelope, outboxSize), - ctrlOutbox: make(chan websocketEnvelope, websocketControlQueueSize), - writeTimeout: writeTimeout, - done: make(chan struct{}), - } -} -func TestEnqueueEnvelopeWaitsForDrainedSlot(t *testing.T) { - t.Parallel() - - c := newEnqueueTestConnection(1, 500*time.Millisecond) - c.outbox <- websocketEnvelope{Type: "ping"} - - go func() { - time.Sleep(10 * time.Millisecond) - <-c.outbox - }() - - if err := c.enqueueEnvelope(websocketEnvelope{Type: "chat.event"}); err != nil { - t.Fatalf("enqueueEnvelope with draining outbox = %v, want nil", err) - } -} - -func TestEnqueueEnvelopeFailsAfterPersistentBacklog(t *testing.T) { - t.Parallel() + "github.com/liveagent/agent-gateway/internal/transport/wscore" +) - c := newEnqueueTestConnection(1, 50*time.Millisecond) - c.outbox <- websocketEnvelope{Type: "ping"} +// 写泵队列语义(等待/掉帧/关闭/心跳静默丢弃)测试已随运行时抽取移至 internal/transport/wscore/conn_test.go;本文件只测 v1 层职责:信封到帧类别的分类与包装后的关键端到端行为。 - started := time.Now() - err := c.enqueueEnvelope(websocketEnvelope{Type: "chat.event"}) - if !errors.Is(err, errWriteQueueFull) { - t.Fatalf("enqueueEnvelope with stuck outbox = %v, want errWriteQueueFull", err) - } - if waited := time.Since(started); waited < 50*time.Millisecond { - t.Fatalf("enqueueEnvelope gave up after %s, want at least the 50ms write timeout", waited) - } +func newEnqueueTestConnection(outboxSize int, writeTimeout time.Duration) *websocketConnection { + core := wscore.NewConn(nil, wscore.Config{ + QueueSize: outboxSize, + WriteTimeout: writeTimeout, + }) + return &websocketConnection{core: core, done: core.Done()} } -func TestEnqueueEnvelopeReturnsWhenConnectionCloses(t *testing.T) { +func TestClassifyEnvelope(t *testing.T) { t.Parallel() - c := newEnqueueTestConnection(1, time.Second) - c.outbox <- websocketEnvelope{Type: "ping"} - - go func() { - time.Sleep(10 * time.Millisecond) - close(c.done) - }() - - err := c.enqueueEnvelope(websocketEnvelope{Type: "chat.event"}) - if err == nil || err.Error() != "connection closed" { - t.Fatalf("enqueueEnvelope on closed connection = %v, want connection closed", err) + cases := []struct { + name string + envelope websocketEnvelope + want wscore.FrameClass + }{ + {"ping", websocketEnvelope{Type: "ping"}, wscore.FramePing}, + {"error", websocketEnvelope{Type: "error"}, wscore.FrameControl}, + {"subscription reset", websocketEnvelope{Type: "chat.subscription_reset"}, wscore.FrameControl}, + {"command update", websocketEnvelope{Type: "chat.command_update"}, wscore.FrameControl}, + {"priority response", websocketEnvelope{ID: "r1", Type: "response", priority: true}, wscore.FrameControl}, + {"correlated response", websocketEnvelope{ID: "r1", Type: "response"}, wscore.FrameResponse}, + {"uncorrelated response", websocketEnvelope{Type: "response"}, wscore.FrameData}, + {"event", websocketEnvelope{Type: "chat.event"}, wscore.FrameData}, + } + for _, tc := range cases { + if got := classifyEnvelope(tc.envelope); got != tc.want { + t.Fatalf("classifyEnvelope(%s) = %d, want %d", tc.name, got, tc.want) + } } } @@ -74,17 +53,17 @@ func TestWriteEnvelopeRoutesControlTypesToControlQueue(t *testing.T) { t.Fatalf("writeEnvelope(%q) = %v, want nil", envelopeType, err) } } - if got := len(c.ctrlOutbox); got != 4 { + if got := len(c.core.CtrlOutbox); got != 4 { t.Fatalf("control queue depth = %d, want 4", got) } - if got := len(c.outbox); got != 0 { + if got := len(c.core.Outbox); got != 0 { t.Fatalf("data queue depth = %d, want 0", got) } if err := c.writeEnvelope(websocketEnvelope{Type: "chat.event"}); err != nil { t.Fatalf("writeEnvelope(chat.event) = %v, want nil", err) } - if got := len(c.outbox); got != 1 { + if got := len(c.core.Outbox); got != 1 { t.Fatalf("data queue depth after chat.event = %d, want 1", got) } } @@ -93,14 +72,24 @@ func TestWritePriorityResponseUsesControlQueue(t *testing.T) { t.Parallel() c := newEnqueueTestConnection(1, 50*time.Millisecond) - c.outbox <- websocketEnvelope{Type: "chat.event"} + c.core.Outbox <- wscore.Frame{Kind: "chat.event"} if err := c.writePriorityResponse("cmd-1", map[string]any{"run_id": "run-1"}); err != nil { t.Fatalf("writePriorityResponse: %v", err) } select { - case envelope := <-c.ctrlOutbox: - if envelope.ID != "cmd-1" || envelope.Type != "response" || !envelope.priority { + case frame := <-c.core.CtrlOutbox: + if frame.Class != wscore.FrameControl || frame.RequestID != "cmd-1" || frame.Kind != "response" { + t.Fatalf("priority response frame = %#v", frame) + } + var envelope struct { + ID string `json:"id"` + Type string `json:"type"` + } + if err := json.Unmarshal(frame.Data, &envelope); err != nil { + t.Fatalf("decode priority response frame: %v", err) + } + if envelope.ID != "cmd-1" || envelope.Type != "response" { t.Fatalf("priority response envelope = %#v", envelope) } default: @@ -112,7 +101,7 @@ func TestWriteEnvelopeQueueFullDoesNotCloseConnection(t *testing.T) { t.Parallel() c := newEnqueueTestConnection(1, 20*time.Millisecond) - c.outbox <- websocketEnvelope{Type: "chat.event"} + c.core.Outbox <- wscore.Frame{Kind: "chat.event"} err := c.writeEnvelope(websocketEnvelope{Type: "chat.event"}) if !errors.Is(err, errWriteQueueFull) { @@ -123,8 +112,8 @@ func TestWriteEnvelopeQueueFullDoesNotCloseConnection(t *testing.T) { t.Fatal("writeEnvelope closed the connection on a full data queue") default: } - if got := c.droppedFrames.Load(); got != 1 { - t.Fatalf("droppedFrames = %d, want 1", got) + if got := c.core.DroppedFrames(); got != 1 { + t.Fatalf("DroppedFrames = %d, want 1", got) } } @@ -132,7 +121,7 @@ func TestWriteResponseQueueFullClosesConnectionForRecovery(t *testing.T) { t.Parallel() c := newEnqueueTestConnection(1, 20*time.Millisecond) - c.outbox <- websocketEnvelope{Type: "chat.event"} + c.core.Outbox <- wscore.Frame{Kind: "chat.event"} err := c.writeResponse("history-1", map[string]any{"total_count": 1}) if !errors.Is(err, errWriteQueueFull) { @@ -146,28 +135,3 @@ func TestWriteResponseQueueFullClosesConnectionForRecovery(t *testing.T) { t.Fatal("writeResponse left the connection open after dropping a correlated response") } } - -func TestEnqueueControlEnvelopeDropsPingWhenFull(t *testing.T) { - t.Parallel() - - c := newEnqueueTestConnection(1, time.Second) - for range websocketControlQueueSize { - c.ctrlOutbox <- websocketEnvelope{Type: "error"} - } - - started := time.Now() - if err := c.writeEnvelope(websocketEnvelope{Type: "ping"}); err != nil { - t.Fatalf("writeEnvelope(ping) with full control queue = %v, want nil (dropped)", err) - } - if waited := time.Since(started); waited > 100*time.Millisecond { - t.Fatalf("ping enqueue blocked for %s, want immediate drop", waited) - } - if got := c.droppedFrames.Load(); got != 1 { - t.Fatalf("droppedFrames = %d, want 1", got) - } - select { - case <-c.done: - t.Fatal("dropped ping closed the connection") - default: - } -} diff --git a/crates/agent-gateway/internal/session/unary.go b/crates/agent-gateway/internal/session/unary.go new file mode 100644 index 000000000..e489840ea --- /dev/null +++ b/crates/agent-gateway/internal/session/unary.go @@ -0,0 +1,33 @@ +package session + +import ( + "context" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +// AwaitUnaryResponse 以单次请求-响应语义向桌面端发送信封并等待首条关联响应; +// v1/v2 协议层共用,取消/超时由调用方 ctx 控制。 +func (m *Manager) AwaitUnaryResponse( + ctx context.Context, + requestID string, + envelope *gatewayv1.GatewayEnvelope, +) (*gatewayv1.AgentEnvelope, error) { + ch, done, cleanup, err := m.RegisterStreamAndSendContext(ctx, requestID, envelope) + if err != nil { + return nil, err + } + defer cleanup() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-done: + return nil, ErrAgentOffline + case env, ok := <-ch: + if !ok { + return nil, ErrAgentOffline + } + return env, nil + } +} diff --git a/crates/agent-gateway/internal/transport/wscore/conn.go b/crates/agent-gateway/internal/transport/wscore/conn.go new file mode 100644 index 000000000..3021113ba --- /dev/null +++ b/crates/agent-gateway/internal/transport/wscore/conn.go @@ -0,0 +1,303 @@ +package wscore + +import ( + "errors" + "log/slog" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +// 写泵行为常量,逐字保持 v1 取值。 +const ( + // DefaultQueueSize 是数据队列默认容量。 + DefaultQueueSize = 512 + // DefaultCtrlQueueSize 是控制队列默认容量。 + DefaultCtrlQueueSize = 64 + + maxWriteRetries = 2 + retryBackoff = 100 * time.Millisecond + defaultHeartbeatPeriod = 15 * time.Second + heartbeatGraceFloor = 5 * time.Second + defaultControlWriteWait = 10 * time.Second + // writeLoopBatchSize 是一次唤醒最多连续写出的帧数(控制帧优先穿插)。 + writeLoopBatchSize = 64 +) + +// Config 是连接运行时的行为参数;零值字段取默认。 +type Config struct { + // WriteTimeout 同时用作单帧写超时与入队等待上限。 + WriteTimeout time.Duration + // QueueSize / CtrlQueueSize 为两条队列的容量。 + QueueSize int + CtrlQueueSize int + // HeartbeatPeriod / HeartbeatGrace 决定心跳周期与空闲驱逐窗口(IdleTimeout = 3*period + grace)。 + HeartbeatPeriod time.Duration + HeartbeatGrace time.Duration + // Remote 是掉帧日志中的对端标识(通常为 RemoteAddr)。 + Remote string + // OnClose 在连接关闭时恰好回调一次(done 已关闭、底层 ws 尚未关闭),供协议层清理订阅等资源。 + OnClose func() +} + +// Conn 是单条 WebSocket 连接的传输运行时。Outbox/CtrlOutbox 由任意 goroutine 经 Enqueue +// 生产、由唯一写泵 goroutine 消费;两通道导出仅为白盒测试,业务代码一律走 Enqueue。 +type Conn struct { + // Outbox 是数据队列(写泵独占消费;除测试外勿直接读写)。 + Outbox chan Frame + // CtrlOutbox 是控制队列,写泵优先消费它,使拥塞无法饿死心跳与流恢复信号。 + CtrlOutbox chan Frame + + ws *websocket.Conn + cfg Config + + writeMu sync.Mutex + droppedFrames atomic.Int64 + + closeOnce sync.Once + done chan struct{} + + // authorized 只在鉴权成功后置位;置位前入站活动不刷新读超时——客户端只有一个 + // IdleTimeout 窗口完成鉴权。 + authorized atomic.Bool + + lastInboundMu sync.Mutex + lastInboundAt time.Time + + writeLoopOnce sync.Once + heartbeatOnce sync.Once +} + +// NewConn 构造连接运行时。ws 允许为 nil(仅入队语义的单元测试)。 +func NewConn(ws *websocket.Conn, cfg Config) *Conn { + if cfg.QueueSize <= 0 { + cfg.QueueSize = DefaultQueueSize + } + if cfg.CtrlQueueSize <= 0 { + cfg.CtrlQueueSize = DefaultCtrlQueueSize + } + return &Conn{ + Outbox: make(chan Frame, cfg.QueueSize), + CtrlOutbox: make(chan Frame, cfg.CtrlQueueSize), + ws: ws, + cfg: cfg, + done: make(chan struct{}), + } +} + +// Done 返回连接关闭信号通道。 +func (c *Conn) Done() <-chan struct{} { + return c.done +} + +// Close 幂等关闭连接:先发布 done、回调 OnClose 清理,最后关底层 ws。 +func (c *Conn) Close() { + c.closeOnce.Do(func() { + close(c.done) + if c.cfg.OnClose != nil { + c.cfg.OnClose() + } + if c.ws != nil { + _ = c.ws.Close() + } + }) +} + +// SetAuthorized 标记鉴权完成;此后入站活动开始刷新读超时。 +func (c *Conn) SetAuthorized() { + c.authorized.Store(true) +} + +// TouchInboundActivity 记录入站活动并(鉴权后)后推读超时;读循环收到任何帧及 WS pong 回调须调用。 +func (c *Conn) TouchInboundActivity() { + c.lastInboundMu.Lock() + c.lastInboundAt = time.Now() + c.lastInboundMu.Unlock() + if !c.authorized.Load() || c.ws == nil { + return + } + _ = c.ws.SetReadDeadline(time.Now().Add(c.IdleTimeout())) +} + +// IdleTimeout 是空闲驱逐窗口:3 个心跳周期加宽限。 +func (c *Conn) IdleTimeout() time.Duration { + period := c.cfg.HeartbeatPeriod + if period <= 0 { + period = defaultHeartbeatPeriod + } + grace := c.cfg.HeartbeatGrace + if grace <= 0 { + grace = heartbeatGraceFloor + } + return period*3 + grace +} + +// ControlWriteTimeout 是入队等待与控制帧写出的时间上限。 +func (c *Conn) ControlWriteTimeout() time.Duration { + if c.cfg.WriteTimeout > 0 { + return c.cfg.WriteTimeout + } + return defaultControlWriteWait +} + +// DroppedFrames 返回累计掉帧数(观测与测试用)。 +func (c *Conn) DroppedFrames() int64 { + return c.droppedFrames.Load() +} + +// Enqueue 将帧交给写泵:控制/心跳帧走优先队列;数据帧持续拥塞时丢弃并返回 +// ErrWriteQueueFull;FrameResponse 掉帧则关连接,让客户端重连重试而非挂到超时。 +func (c *Conn) Enqueue(frame Frame) error { + if frame.Class == FrameControl || frame.Class == FramePing { + return c.enqueueControl(frame) + } + err := c.enqueueData(frame) + if errors.Is(err, ErrWriteQueueFull) { + c.noteDroppedFrame(frame.Kind) + if frame.Class == FrameResponse { + c.Close() + } + } + return err +} + +// enqueueData 在数据队列瞬时满载时最多等 ControlWriteTimeout,持续积压才报 ErrWriteQueueFull;快速路径零分配。 +func (c *Conn) enqueueData(frame Frame) error { + select { + case <-c.done: + return errors.New("connection closed") + case c.Outbox <- frame: + return nil + default: + } + + timer := time.NewTimer(c.ControlWriteTimeout()) + defer timer.Stop() + select { + case <-c.done: + return errors.New("connection closed") + case c.Outbox <- frame: + return nil + case <-timer.C: + return ErrWriteQueueFull + } +} + +func (c *Conn) enqueueControl(frame Frame) error { + select { + case <-c.done: + return errors.New("connection closed") + case c.CtrlOutbox <- frame: + return nil + default: + } + + if frame.Class == FramePing { + // 心跳是周期性的:控制队列满时静默丢弃,下个周期自然取代。 + c.noteDroppedFrame(frame.Kind) + return nil + } + + timer := time.NewTimer(c.ControlWriteTimeout()) + defer timer.Stop() + select { + case <-c.done: + return errors.New("connection closed") + case c.CtrlOutbox <- frame: + return nil + case <-timer.C: + c.noteDroppedFrame(frame.Kind) + return ErrWriteQueueFull + } +} + +func (c *Conn) noteDroppedFrame(kind string) { + dropped := c.droppedFrames.Add(1) + // 只记第一次与每第 100 次:生产可见掉帧,突发期不刷屏。 + if dropped == 1 || dropped%100 == 0 { + slog.Warn("websocket: shed frame for slow client", + "kind", kind, + "dropped", dropped, + "remote", c.cfg.Remote, + ) + } +} + +// StartWriteLoop 启动写泵(幂等),协议层在鉴权成功后调用——鉴权前队列无人消费。 +func (c *Conn) StartWriteLoop() { + c.writeLoopOnce.Do(func() { + go c.writeLoop() + }) +} + +// writeLoop 优先清空控制队列再消费数据队列,使拥塞无法饿死心跳与流恢复帧。 +func (c *Conn) writeLoop() { + for { + select { + case <-c.done: + return + case frame := <-c.CtrlOutbox: + if !c.deliverFrame(frame) { + return + } + case frame := <-c.Outbox: + if !c.deliverFrame(frame) { + return + } + for drained := 0; drained < writeLoopBatchSize; drained++ { + select { + case extra := <-c.CtrlOutbox: + if !c.deliverFrame(extra) { + return + } + continue + default: + } + select { + case extra := <-c.Outbox: + if !c.deliverFrame(extra) { + return + } + default: + goto batchDone + } + } + batchDone: + } + } +} + +// deliverFrame 带有限重试地写出一帧;重试耗尽即底层链路不可写,是唯一允许据此关连接的路径。 +func (c *Conn) deliverFrame(frame Frame) bool { + if err := c.writeFrameDirect(frame); err == nil { + return true + } + for attempt := 0; attempt < maxWriteRetries; attempt++ { + select { + case <-c.done: + return false + case <-time.After(retryBackoff): + } + if err := c.writeFrameDirect(frame); err == nil { + return true + } + } + c.Close() + return false +} + +func (c *Conn) writeFrameDirect(frame Frame) error { + c.writeMu.Lock() + defer c.writeMu.Unlock() + if c.cfg.WriteTimeout > 0 { + if err := c.ws.SetWriteDeadline(time.Now().Add(c.cfg.WriteTimeout)); err != nil { + return err + } + defer func() { + _ = c.ws.SetWriteDeadline(time.Time{}) + }() + } + return c.ws.WriteMessage(frame.MessageType, frame.Data) +} diff --git a/crates/agent-gateway/internal/transport/wscore/conn_test.go b/crates/agent-gateway/internal/transport/wscore/conn_test.go new file mode 100644 index 000000000..cd9b5f887 --- /dev/null +++ b/crates/agent-gateway/internal/transport/wscore/conn_test.go @@ -0,0 +1,169 @@ +package wscore + +import ( + "errors" + "testing" + "time" +) + +// v1 写泵语义测试的等价移植(原 internal/server/websocket_write_test.go):队列路由、 +// 拥塞掉帧、关联响应关连接、心跳静默丢弃等行为必须逐一保持。 + +func newTestConn(queueSize int, writeTimeout time.Duration) *Conn { + return NewConn(nil, Config{ + QueueSize: queueSize, + WriteTimeout: writeTimeout, + }) +} + +func TestEnqueueDataWaitsForDrainedSlot(t *testing.T) { + t.Parallel() + + c := newTestConn(1, 500*time.Millisecond) + c.Outbox <- Frame{Kind: "ping"} + + go func() { + time.Sleep(10 * time.Millisecond) + <-c.Outbox + }() + + if err := c.Enqueue(Frame{Class: FrameData, Kind: "chat.event"}); err != nil { + t.Fatalf("Enqueue with draining outbox = %v, want nil", err) + } +} + +func TestEnqueueDataFailsAfterPersistentBacklog(t *testing.T) { + t.Parallel() + + c := newTestConn(1, 50*time.Millisecond) + c.Outbox <- Frame{Kind: "ping"} + + started := time.Now() + err := c.Enqueue(Frame{Class: FrameData, Kind: "chat.event"}) + if !errors.Is(err, ErrWriteQueueFull) { + t.Fatalf("Enqueue with stuck outbox = %v, want ErrWriteQueueFull", err) + } + if waited := time.Since(started); waited < 50*time.Millisecond { + t.Fatalf("Enqueue gave up after %s, want at least the 50ms write timeout", waited) + } +} + +func TestEnqueueReturnsWhenConnectionCloses(t *testing.T) { + t.Parallel() + + c := newTestConn(1, time.Second) + c.Outbox <- Frame{Kind: "ping"} + + go func() { + time.Sleep(10 * time.Millisecond) + c.Close() + }() + + err := c.Enqueue(Frame{Class: FrameData, Kind: "chat.event"}) + if err == nil || err.Error() != "connection closed" { + t.Fatalf("Enqueue on closed connection = %v, want connection closed", err) + } +} + +func TestControlFramesRouteToControlQueue(t *testing.T) { + t.Parallel() + + c := newTestConn(1, 50*time.Millisecond) + + if err := c.Enqueue(Frame{Class: FramePing, Kind: "ping"}); err != nil { + t.Fatalf("Enqueue(ping) = %v, want nil", err) + } + for _, kind := range []string{"error", "chat.subscription_reset", "chat.command_update"} { + if err := c.Enqueue(Frame{Class: FrameControl, Kind: kind}); err != nil { + t.Fatalf("Enqueue(%q) = %v, want nil", kind, err) + } + } + if got := len(c.CtrlOutbox); got != 4 { + t.Fatalf("control queue depth = %d, want 4", got) + } + if got := len(c.Outbox); got != 0 { + t.Fatalf("data queue depth = %d, want 0", got) + } + + if err := c.Enqueue(Frame{Class: FrameData, Kind: "chat.event"}); err != nil { + t.Fatalf("Enqueue(chat.event) = %v, want nil", err) + } + if got := len(c.Outbox); got != 1 { + t.Fatalf("data queue depth after chat.event = %d, want 1", got) + } +} + +func TestDataQueueFullDoesNotCloseConnection(t *testing.T) { + t.Parallel() + + c := newTestConn(1, 20*time.Millisecond) + c.Outbox <- Frame{Kind: "chat.event"} + + err := c.Enqueue(Frame{Class: FrameData, Kind: "chat.event"}) + if !errors.Is(err, ErrWriteQueueFull) { + t.Fatalf("Enqueue with stuck outbox = %v, want ErrWriteQueueFull", err) + } + select { + case <-c.Done(): + t.Fatal("Enqueue closed the connection on a full data queue") + default: + } + if got := c.DroppedFrames(); got != 1 { + t.Fatalf("DroppedFrames = %d, want 1", got) + } +} + +func TestResponseQueueFullClosesConnectionForRecovery(t *testing.T) { + t.Parallel() + + c := newTestConn(1, 20*time.Millisecond) + c.Outbox <- Frame{Kind: "chat.event"} + + err := c.Enqueue(Frame{Class: FrameResponse, RequestID: "history-1", Kind: "response"}) + if !errors.Is(err, ErrWriteQueueFull) { + t.Fatalf("Enqueue(response) with stuck outbox = %v, want ErrWriteQueueFull", err) + } + select { + case <-c.Done(): + // 预期:客户端观察到断连即可恢复该关联请求,而非等一个被静默丢弃的响应到超时。 + default: + t.Fatal("dropping a correlated response left the connection open") + } +} + +func TestPingDroppedSilentlyWhenControlQueueFull(t *testing.T) { + t.Parallel() + + c := newTestConn(1, time.Second) + for range DefaultCtrlQueueSize { + c.CtrlOutbox <- Frame{Kind: "error"} + } + + started := time.Now() + if err := c.Enqueue(Frame{Class: FramePing, Kind: "ping"}); err != nil { + t.Fatalf("Enqueue(ping) with full control queue = %v, want nil (dropped)", err) + } + if waited := time.Since(started); waited > 100*time.Millisecond { + t.Fatalf("ping enqueue blocked for %s, want immediate drop", waited) + } + if got := c.DroppedFrames(); got != 1 { + t.Fatalf("DroppedFrames = %d, want 1", got) + } + select { + case <-c.Done(): + t.Fatal("dropped ping closed the connection") + default: + } +} + +func TestOnCloseRunsExactlyOnce(t *testing.T) { + t.Parallel() + + calls := 0 + c := NewConn(nil, Config{OnClose: func() { calls++ }}) + c.Close() + c.Close() + if calls != 1 { + t.Fatalf("OnClose calls = %d, want 1", calls) + } +} diff --git a/crates/agent-gateway/internal/transport/wscore/frame.go b/crates/agent-gateway/internal/transport/wscore/frame.go new file mode 100644 index 000000000..b44f9bb9c --- /dev/null +++ b/crates/agent-gateway/internal/transport/wscore/frame.go @@ -0,0 +1,36 @@ +// Package wscore 提供 v1/v2 WebSocket 协议共用的连接运行时(优先级双队列写泵、拥塞掉帧、 +// 有限重试、空闲驱逐与心跳)。对帧格式无感:帧以已编码字节入队,协议层负责编码并声明 +// 拥塞策略(Frame.Class)。行为与常量逐字移植自 v1,由等价单元测试固定,无语义变化。 +package wscore + +import "errors" + +// ErrWriteQueueFull 表示帧因持续拥塞被丢弃;协议层可据此对单个流降级恢复而不牺牲整条连接。 +var ErrWriteQueueFull = errors.New("write queue full") + +// FrameClass 决定帧的入队队列与拥塞策略。 +type FrameClass uint8 + +const ( + // FrameData 是可掉帧的事件/广播数据:走数据队列,持续拥塞时丢弃并返回 ErrWriteQueueFull。 + FrameData FrameClass = iota + // FrameControl 是尽力送达的控制帧:走优先队列越过数据积压;持续拥塞时丢弃报错但不关连接。 + FrameControl + // FramePing 是周期心跳:走优先队列,队列满时静默丢弃(下个周期取代)。 + FramePing + // FrameResponse 是请求关联响应:静默丢弃会让客户端挂到超时,故持续拥塞时关连接促使重连重试。 + FrameResponse +) + +// Frame 是写泵承载的单帧描述,载荷为已编码字节。 +type Frame struct { + Class FrameClass + // RequestID 为关联响应的请求 id,仅用于诊断。 + RequestID string + // Kind 是帧类型标签(v1 信封 type / v2 oneof 臂名),仅用于掉帧日志与测试断言。 + Kind string + // MessageType 为 websocket.TextMessage 或 websocket.BinaryMessage。 + MessageType int + // Data 为完整的已编码帧载荷。 + Data []byte +} diff --git a/crates/agent-gateway/internal/transport/wscore/heartbeat.go b/crates/agent-gateway/internal/transport/wscore/heartbeat.go new file mode 100644 index 000000000..43d918343 --- /dev/null +++ b/crates/agent-gateway/internal/transport/wscore/heartbeat.go @@ -0,0 +1,44 @@ +package wscore + +import ( + "time" + + "github.com/gorilla/websocket" +) + +// StartHeartbeat 启动心跳与空闲驱逐循环(幂等),每周期依次:空闲检查(超过 IdleTimeout +// 即关连接,唯一驱逐裁决点);WS 控制帧 ping(浏览器网络进程应答,冻结/节流标签页也能证明存活); +// buildPing 构造的应用层 ping(页面 JS 唯一可观测入站活动,尽力而为,掉帧不影响驱逐裁决)。 +func (c *Conn) StartHeartbeat(buildPing func() (Frame, bool)) { + c.heartbeatOnce.Do(func() { + period := c.cfg.HeartbeatPeriod + if period <= 0 { + period = defaultHeartbeatPeriod + } + go func() { + ticker := time.NewTicker(period) + defer ticker.Stop() + for { + select { + case <-c.done: + return + case <-ticker.C: + c.lastInboundMu.Lock() + lastInbound := c.lastInboundAt + c.lastInboundMu.Unlock() + if time.Since(lastInbound) > c.IdleTimeout() { + c.Close() + return + } + deadline := time.Now().Add(c.ControlWriteTimeout()) + _ = c.ws.WriteControl(websocket.PingMessage, nil, deadline) + if buildPing != nil { + if frame, ok := buildPing(); ok { + _ = c.Enqueue(frame) + } + } + } + } + }() + }) +} From d6abce737c591268f71a91f40d3c73dc8a86d675 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:19:47 +0800 Subject: [PATCH 05/10] feat(gateway): serve v2 WebSocket+Protobuf protocol on /ws/v2* - internal/protocol/pbws: browser link (envelope passthrough with whitelist guard and per-connection request-id namespacing, local chat/status/workspace ops, nine broadcast fanouts, snapshot replay), agent link (replaces gRPC Authenticate+AgentConnect), terminal link (proto frames for both roles) - routes mounted beside v1; /api/status exposes protocol_usage counters - integration suite incl. full binary browser<->gateway<->agent path; encode benchmark: ~47x faster, 52% smaller than v1 JSON shaping --- .../agent-gateway/internal/handler/status.go | 12 +- .../internal/protocol/pbws/agent_conn.go | 234 ++++++++++ .../internal/protocol/pbws/browser_conn.go | 262 +++++++++++ .../internal/protocol/pbws/browser_events.go | 408 ++++++++++++++++++ .../internal/protocol/pbws/browser_local.go | 255 +++++++++++ .../internal/protocol/pbws/browser_relay.go | 72 ++++ .../internal/protocol/pbws/guard.go | 115 +++++ .../internal/protocol/pbws/handshake.go | 59 +++ .../internal/protocol/pbws/seam.go | 92 ++++ .../internal/protocol/pbws/server.go | 111 +++++ .../internal/protocol/pbws/terminal_conn.go | 393 +++++++++++++++++ crates/agent-gateway/internal/server/http.go | 8 + .../server/websocket_payload_bench_test.go | 92 ++++ .../test/websocket/v2_chat_terminal_test.go | 160 +++++++ .../test/websocket/v2_helpers_test.go | 155 +++++++ .../agent-gateway/test/websocket/v2_test.go | 341 +++++++++++++++ 16 files changed, 2768 insertions(+), 1 deletion(-) create mode 100644 crates/agent-gateway/internal/protocol/pbws/agent_conn.go create mode 100644 crates/agent-gateway/internal/protocol/pbws/browser_conn.go create mode 100644 crates/agent-gateway/internal/protocol/pbws/browser_events.go create mode 100644 crates/agent-gateway/internal/protocol/pbws/browser_local.go create mode 100644 crates/agent-gateway/internal/protocol/pbws/browser_relay.go create mode 100644 crates/agent-gateway/internal/protocol/pbws/guard.go create mode 100644 crates/agent-gateway/internal/protocol/pbws/handshake.go create mode 100644 crates/agent-gateway/internal/protocol/pbws/seam.go create mode 100644 crates/agent-gateway/internal/protocol/pbws/server.go create mode 100644 crates/agent-gateway/internal/protocol/pbws/terminal_conn.go create mode 100644 crates/agent-gateway/internal/server/websocket_payload_bench_test.go create mode 100644 crates/agent-gateway/test/websocket/v2_chat_terminal_test.go create mode 100644 crates/agent-gateway/test/websocket/v2_helpers_test.go create mode 100644 crates/agent-gateway/test/websocket/v2_test.go diff --git a/crates/agent-gateway/internal/handler/status.go b/crates/agent-gateway/internal/handler/status.go index faec4e6a9..a4b89d223 100644 --- a/crates/agent-gateway/internal/handler/status.go +++ b/crates/agent-gateway/internal/handler/status.go @@ -3,11 +3,21 @@ package handler import ( "net/http" + "github.com/liveagent/agent-gateway/internal/observability" "github.com/liveagent/agent-gateway/internal/session" ) +// statusResponse 在 agent 状态之上追加协议使用计数;内嵌保持既有字段展平不变,protocol_usage 为纯增量字段,旧客户端自动忽略。 +type statusResponse struct { + session.Status + ProtocolUsage map[string]int64 `json:"protocol_usage"` +} + func Status(sm *session.Manager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, sm.Status()) + writeJSON(w, http.StatusOK, statusResponse{ + Status: sm.Status(), + ProtocolUsage: observability.Usage.Snapshot(), + }) } } diff --git a/crates/agent-gateway/internal/protocol/pbws/agent_conn.go b/crates/agent-gateway/internal/protocol/pbws/agent_conn.go new file mode 100644 index 000000000..1c7ea7c71 --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/agent_conn.go @@ -0,0 +1,234 @@ +package pbws + +import ( + "context" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" + + "github.com/liveagent/agent-gateway/internal/observability" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/session" +) + +// AgentHandler 返回 /ws/v2/agent 的 HTTP 处理器,替代 v1 gRPC Authenticate+AgentConnect: +// hello 一并完成鉴权与会话登记,之后的双向信封流语义与 grpc.go 逐一对应。 +func (s *Server) AgentHandler() http.Handler { + upgrader := s.upgrader() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + conn.SetReadLimit(s.readLimit()) + s.serveAgent(conn) + }) +} + +func (s *Server) serveAgent(conn *websocket.Conn) { + defer func() { _ = conn.Close() }() + + // ---- 握手:hello 即鉴权 + 会话登记(等价 Authenticate RPC)---- + frame, ok := readAgentFrame(conn) + if !ok { + return + } + hello := frame.GetHello() + verdict := s.vetHello(hello, gatewayv2.ClientRole_CLIENT_ROLE_AGENT) + if !verdict.ok { + _ = writeDirectMessage(conn, s.writeTimeout(), &gatewayv2.AgentServerFrame{ + Payload: &gatewayv2.AgentServerFrame_Hello{ + Hello: s.serverHello(false, verdict.message, ""), + }, + }) + closeUnauthorized(conn, s.writeTimeout()) + return + } + + sessionID := uuid.NewString() + s.sm.RecordAuthentication(hello.GetAgentId(), hello.GetAgentVersion(), sessionID) + if err := writeDirectMessage(conn, s.writeTimeout(), &gatewayv2.AgentServerFrame{ + Payload: &gatewayv2.AgentServerFrame_Hello{ + Hello: s.serverHello(true, "", sessionID), + }, + }); err != nil { + return + } + + observability.Usage.V2AgentConnectsTotal.Add(1) + observability.Usage.V2AgentActive.Add(1) + defer observability.Usage.V2AgentActive.Add(-1) + + // ---- 会话接管(等价 AgentConnect 流建立)---- + sess := session.NewAgentSession(s.sm.LatestAuthSnapshot()) + toAgent := sess.Outbound() + s.sm.SetSession(sess) + defer s.sm.ClearSession(sess) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + select { + case <-ctx.Done(): + case <-sess.Done(): + cancel() + } + }() + // ctx 结束时关闭底层连接,解除读循环的阻塞。 + go func() { + <-ctx.Done() + _ = conn.Close() + }() + + go s.agentHeartbeatLoop(ctx, conn, sess) + + // WS 控制帧 pong 计入桌面端存活(对应 h2 keepalive 的职能)。 + conn.SetPongHandler(func(string) error { + s.sm.TouchHeartbeat(sess) + return nil + }) + + // ---- 出站泵:心跳专用通道优先,拥塞永远饿不死保活 ---- + go func() { + defer cancel() + pings := sess.Pings() + for { + select { + case ping := <-pings: + if !s.writeAgentEnvelope(conn, ping) { + return + } + continue + default: + } + select { + case <-ctx.Done(): + return + case <-sess.Done(): + return + case ping := <-pings: + if !s.writeAgentEnvelope(conn, ping) { + return + } + case outbound := <-toAgent: + if outbound == nil || outbound.GatewayEnvelope == nil { + continue + } + select { + case <-outbound.Context().Done(): + outbound.Ack(outbound.Context().Err()) + continue + default: + } + if !s.writeAgentEnvelope(conn, outbound.GatewayEnvelope) { + outbound.Ack(context.Canceled) + return + } + outbound.Ack(nil) + } + } + }() + + // ---- 入站循环 ---- + for { + frame, ok := readAgentFrame(conn) + if !ok { + cancel() + return + } + env := frame.GetEnvelope() + if env == nil { + // 重复 hello 或空帧:忽略(仍计入存活)。 + s.sm.TouchHeartbeat(sess) + continue + } + // 任何入站信封都证明桌面端存活;活跃流式传输中的 agent 绝不能被判心跳过期。 + s.sm.TouchHeartbeat(sess) + // Pong 与其他信封同一分发:关联探测按 request_id 命中注册流,周期心跳的 Pong 无注册流、被无害忽略。 + s.sm.DispatchFromAgentForSession(sess, env) + } +} + +func readAgentFrame(conn *websocket.Conn) (*gatewayv2.AgentClientFrame, bool) { + for { + messageType, data, err := conn.ReadMessage() + if err != nil { + return nil, false + } + if messageType != websocket.BinaryMessage { + continue + } + var frame gatewayv2.AgentClientFrame + if err := proto.Unmarshal(data, &frame); err != nil { + return nil, false + } + return &frame, true + } +} + +// writeAgentEnvelope 序列化并写出一条 GatewayEnvelope 帧(单写者无需互斥;WriteControl 与之并发安全)。 +func (s *Server) writeAgentEnvelope(conn *websocket.Conn, env *gatewayv1.GatewayEnvelope) bool { + data, err := proto.Marshal(&gatewayv2.AgentServerFrame{ + Payload: &gatewayv2.AgentServerFrame_Envelope{Envelope: env}, + }) + if err != nil { + return false + } + if timeout := s.writeTimeout(); timeout > 0 { + if err := conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil { + return false + } + defer func() { _ = conn.SetWriteDeadline(time.Time{}) }() + } + return conn.WriteMessage(websocket.BinaryMessage, data) == nil +} + +// agentHeartbeatLoop 与 grpc.go 的 heartbeatLoop 语义一致:周期发应用层 Ping(走专用心跳通道)、 +// 驱逐心跳过期会话;额外补发 WS 控制帧 ping,由 tokio-tungstenite 自动 pong 承担传输层保活。 +func (s *Server) agentHeartbeatLoop(ctx context.Context, conn *websocket.Conn, sess *session.AgentSession) { + period := 30 * time.Second + if s.cfg != nil && s.cfg.HeartbeatPeriod > 0 { + period = s.cfg.HeartbeatPeriod + } + ticker := time.NewTicker(period) + defer ticker.Stop() + + if !s.sendAgentHeartbeat(sess) { + return + } + + timeout := period * 3 + for { + select { + case <-ctx.Done(): + return + case <-sess.Done(): + return + case <-ticker.C: + if s.sm.ClearSessionIfHeartbeatStale(sess, timeout) { + return + } + deadline := time.Now().Add(s.writeTimeout()) + _ = conn.WriteControl(websocket.PingMessage, nil, deadline) + if !s.sendAgentHeartbeat(sess) { + return + } + } + } +} + +func (s *Server) sendAgentHeartbeat(sess *session.AgentSession) bool { + return sess.SendPing(&gatewayv1.GatewayEnvelope{ + RequestId: "ping-" + uuid.NewString(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_Ping{ + Ping: &gatewayv1.PingRequest{ + Timestamp: time.Now().Unix(), + }, + }, + }) == nil +} diff --git a/crates/agent-gateway/internal/protocol/pbws/browser_conn.go b/crates/agent-gateway/internal/protocol/pbws/browser_conn.go new file mode 100644 index 000000000..91acaebdc --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/browser_conn.go @@ -0,0 +1,262 @@ +package pbws + +import ( + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" + + "github.com/liveagent/agent-gateway/internal/config" + "github.com/liveagent/agent-gateway/internal/observability" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/protocol/shared" + "github.com/liveagent/agent-gateway/internal/session" + "github.com/liveagent/agent-gateway/internal/transport/wscore" +) + +// browserConnSeq 为每条浏览器连接分配 request_id 命名空间前缀,消除多标签页并发时 +// agent 侧关联 id 冲突的可能。 +var browserConnSeq atomic.Uint64 + +// browserConn 是 /ws/v2 上的一条浏览器连接。 +type browserConn struct { + cfg *config.Config + sm *session.Manager + srv *Server + + conn *websocket.Conn + core *wscore.Conn + done <-chan struct{} + + // idPrefix + 原始 request_id 构成转发给桌面端的关联 id;回程剥离。 + idPrefix string + + terminalInterest *shared.TerminalInterestTracker + + chatStreamsMu sync.Mutex + chatStreams map[string]func() // conversation_id -> 订阅取消 + + workspaceSubsMu sync.Mutex + workspaceSubs map[string]*workspaceSubscription +} + +// BrowserHandler 返回 /ws/v2 的 HTTP 处理器。 +func (s *Server) BrowserHandler() http.Handler { + upgrader := s.upgrader() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + conn.SetReadLimit(s.readLimit()) + + c := &browserConn{ + cfg: s.cfg, + sm: s.sm, + srv: s, + conn: conn, + idPrefix: browserIDPrefix(), + terminalInterest: shared.NewTerminalInterestTracker(), + } + c.core = wscore.NewConn(conn, wscore.Config{ + WriteTimeout: s.cfg.WebSocketWriteTimeout, + QueueSize: s.cfg.WebSocketWriteQueueSize, + HeartbeatPeriod: s.cfg.WebSocketHeartbeatPeriod, + HeartbeatGrace: s.cfg.WebSocketHeartbeatGrace, + Remote: r.RemoteAddr, + OnClose: c.releaseSubscriptions, + }) + c.done = c.core.Done() + // WS 控制帧 pong 由浏览器网络栈应答(后台节流标签页亦然),必须计入存活证据。 + conn.SetPongHandler(func(string) error { + c.core.TouchInboundActivity() + return nil + }) + _ = conn.SetReadDeadline(time.Now().Add(c.core.IdleTimeout())) + defer c.core.Close() + c.serve() + }) +} + +func browserIDPrefix() string { + // 前缀短且进程内唯一即可;对端只做等值回显。 + return "b" + strconv.FormatUint(browserConnSeq.Add(1), 10) + ":" +} + +// serve 是读循环:首帧必须 hello,之后按载荷臂分发。chat/workspace 订阅生命周期帧在读循环 +// 内联执行以保帧序(重订阅连发 [unsubscribe, subscribe],并发分发会让旧退订取消新订阅); +// 其余请求各自 goroutine 处理。 +func (c *browserConn) serve() { + if !c.handshake() { + return + } + + observability.Usage.V2BrowserConnectionsTotal.Add(1) + observability.Usage.V2BrowserConnectionsActive.Add(1) + defer observability.Usage.V2BrowserConnectionsActive.Add(-1) + + for { + frame, ok := c.readFrame() + if !ok { + return + } + c.core.TouchInboundActivity() + + switch payload := frame.GetPayload().(type) { + case *gatewayv2.WebClientFrame_Pong: + continue + case *gatewayv2.WebClientFrame_Hello: + _ = c.sendLocalError(frame.GetRequestId(), "already authenticated") + continue + case *gatewayv2.WebClientFrame_ChatSubscribe, + *gatewayv2.WebClientFrame_ChatUnsubscribe, + *gatewayv2.WebClientFrame_WorkspaceSubscribe, + *gatewayv2.WebClientFrame_WorkspaceUnsubscribe: + c.dispatch(frame) + case nil: + _ = c.sendLocalError(frame.GetRequestId(), "frame payload is required") + continue + default: + _ = payload + go c.dispatch(frame) + } + } +} + +// readFrame 读取并解码一帧;解码失败说明帧流已破坏,直接关闭连接。 +func (c *browserConn) readFrame() (*gatewayv2.WebClientFrame, bool) { + for { + messageType, data, err := c.conn.ReadMessage() + if err != nil { + return nil, false + } + if messageType != websocket.BinaryMessage { + // v2 链路上文本帧无意义;容忍并忽略(仍计入存活)。 + c.core.TouchInboundActivity() + continue + } + var frame gatewayv2.WebClientFrame + if err := proto.Unmarshal(data, &frame); err != nil { + return nil, false + } + return &frame, true + } +} + +// handshake 处理首帧 hello;失败时写出失败应答并关闭。 +func (c *browserConn) handshake() bool { + frame, ok := c.readFrame() + if !ok { + return false + } + hello := frame.GetHello() + verdict := c.srv.vetHello(hello, gatewayv2.ClientRole_CLIENT_ROLE_BROWSER) + if !verdict.ok { + _ = writeDirectMessage(c.conn, c.srv.writeTimeout(), &gatewayv2.WebServerFrame{ + RequestId: frame.GetRequestId(), + Payload: &gatewayv2.WebServerFrame_Hello{ + Hello: c.srv.serverHello(false, verdict.message, ""), + }, + }) + closeUnauthorized(c.conn, c.srv.writeTimeout()) + return false + } + + c.core.SetAuthorized() + // 握手前的读超时刻意未刷新;成功后立即续期。 + c.core.TouchInboundActivity() + c.core.StartWriteLoop() + c.startEventForwarders() + c.core.StartHeartbeat(c.buildHeartbeatPing) + + // hello 应答走数据队列(FrameResponse):与快照回放同队 FIFO,保证客户端先收 hello + // 再收回放帧(跨队列只有优先级、无顺序保证)。 + if err := c.send(wscore.FrameResponse, "hello", &gatewayv2.WebServerFrame{ + RequestId: frame.GetRequestId(), + Payload: &gatewayv2.WebServerFrame_Hello{ + Hello: c.srv.serverHello(true, "", ""), + }, + }); err != nil { + c.core.Close() + return false + } + c.replaySnapshots() + return true +} + +func (c *browserConn) dispatch(frame *gatewayv2.WebClientFrame) { + observability.Usage.V2BrowserRequestsTotal.Add(1) + requestID := strings.TrimSpace(frame.GetRequestId()) + + switch payload := frame.GetPayload().(type) { + case *gatewayv2.WebClientFrame_AgentRequest: + c.handleAgentRequest(requestID, payload.AgentRequest) + case *gatewayv2.WebClientFrame_StatusGet: + c.handleStatusGet(requestID) + case *gatewayv2.WebClientFrame_ChatCommand: + c.handleChatCommand(requestID, payload.ChatCommand) + case *gatewayv2.WebClientFrame_ChatPrepare: + c.handleChatPrepare(requestID, payload.ChatPrepare) + case *gatewayv2.WebClientFrame_ChatSubscribe: + c.handleChatSubscribe(requestID, payload.ChatSubscribe) + case *gatewayv2.WebClientFrame_ChatUnsubscribe: + c.handleChatUnsubscribe(requestID, payload.ChatUnsubscribe) + case *gatewayv2.WebClientFrame_ChatActivities: + c.handleChatActivities(requestID) + case *gatewayv2.WebClientFrame_WorkspaceSubscribe: + c.handleWorkspaceSubscribe(requestID, payload.WorkspaceSubscribe) + case *gatewayv2.WebClientFrame_WorkspaceUnsubscribe: + c.handleWorkspaceUnsubscribe(requestID, payload.WorkspaceUnsubscribe) + default: + _ = c.sendLocalError(requestID, "unsupported frame payload") + } +} + +// send 编码并投递一帧(拥塞策略由帧类别声明,wscore 统一执行)。 +func (c *browserConn) send(class wscore.FrameClass, kind string, frame *gatewayv2.WebServerFrame) error { + data, err := proto.Marshal(frame) + if err != nil { + return err + } + return c.core.Enqueue(wscore.Frame{ + Class: class, + RequestID: frame.GetRequestId(), + Kind: kind, + MessageType: websocket.BinaryMessage, + Data: data, + }) +} + +// sendLocalError 回送网关本地错误(对应 v1 的 type:"error" 信封,走控制队列保证拥塞下可达)。 +func (c *browserConn) sendLocalError(requestID string, message string) error { + return c.send(wscore.FrameControl, "local_error", &gatewayv2.WebServerFrame{ + RequestId: requestID, + Payload: &gatewayv2.WebServerFrame_LocalError{ + LocalError: &gatewayv1.ErrorResponse{Message: message}, + }, + }) +} + +// buildHeartbeatPing 为共享心跳循环构造应用层 PingFrame。 +func (c *browserConn) buildHeartbeatPing() (wscore.Frame, bool) { + data, err := proto.Marshal(&gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_Ping{ + Ping: &gatewayv2.PingFrame{Timestamp: time.Now().Unix()}, + }, + }) + if err != nil { + return wscore.Frame{}, false + } + return wscore.Frame{ + Class: wscore.FramePing, + Kind: "ping", + MessageType: websocket.BinaryMessage, + Data: data, + }, true +} diff --git a/crates/agent-gateway/internal/protocol/pbws/browser_events.go b/crates/agent-gateway/internal/protocol/pbws/browser_events.go new file mode 100644 index 000000000..50d471b1b --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/browser_events.go @@ -0,0 +1,408 @@ +package pbws + +import ( + "encoding/json" + "errors" + "strings" + "sync" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/protocol/shared" + "github.com/liveagent/agent-gateway/internal/session" + "github.com/liveagent/agent-gateway/internal/transport/wscore" +) + +// 浏览器连接的订阅生命周期、九路广播转发与连接后快照回放,语义与 v1 转发器一一对应: +// 广播帧可掉(errWriteQueueFull 跳过继续),chat 会话流掉帧则发订阅重置信号让客户端按 +// after_seq 断点续传。 + +// workspaceSubscription 是一个 workdir 的活动订阅(对应 v1 同名结构)。 +type workspaceSubscription struct { + cancel func() + done chan struct{} + once sync.Once +} + +func (s *workspaceSubscription) close() { + s.once.Do(func() { + close(s.done) + s.cancel() + }) +} + +// releaseSubscriptions 由 core 关闭回调(恰好一次),释放 chat/workspace 订阅; +// 九路广播转发器各自监听 done 退出并 defer cleanup。 +func (c *browserConn) releaseSubscriptions() { + c.chatStreamsMu.Lock() + for conversationID, cancel := range c.chatStreams { + cancel() + delete(c.chatStreams, conversationID) + } + c.chatStreamsMu.Unlock() + + c.workspaceSubsMu.Lock() + for workdir, sub := range c.workspaceSubs { + sub.close() + delete(c.workspaceSubs, workdir) + } + c.workspaceSubsMu.Unlock() +} + +// --------------------------------------------------------------------------- +// chat 会话流订阅 +// --------------------------------------------------------------------------- + +// handleChatSubscribe 对应 v1 "chat.subscribe"(读循环内联执行以保帧序)。 +func (c *browserConn) handleChatSubscribe(requestID string, req *gatewayv2.ChatSubscribeRequest) { + conversationID := strings.TrimSpace(req.GetConversationId()) + if conversationID == "" { + _ = c.sendLocalError(requestID, "conversation_id is required") + return + } + + sub := c.sm.SubscribeConversationStream(conversationID, req.GetAfterSeq(), req.GetStreamEpoch()) + + events := make([][]byte, 0, len(sub.Events)) + for _, event := range sub.Events { + payload, err := json.Marshal(event.Payload) + if err != nil { + continue + } + events = append(events, payload) + } + result := &gatewayv2.ChatSubscribeResult{ + ConversationId: sub.ConversationID, + StreamEpoch: sub.StreamEpoch, + LatestSeq: sub.LatestSeq, + Reset_: sub.Reset, + Activity: chatRunActivity(sub.Activity), + Snapshot: chatRunSnapshot(sub.Snapshot), + EventsJson: events, + } + + // 先登记(替换同会话旧订阅)再应答,避免回放边界之后发布的事件被漏。 + c.chatStreamsMu.Lock() + if c.chatStreams == nil { + c.chatStreams = make(map[string]func()) + } + if previous := c.chatStreams[conversationID]; previous != nil { + previous() + } + c.chatStreams[conversationID] = sub.Cleanup + c.chatStreamsMu.Unlock() + + if err := c.send(wscore.FrameResponse, "chat_subscribed", &gatewayv2.WebServerFrame{ + RequestId: requestID, + Payload: &gatewayv2.WebServerFrame_ChatSubscribed{ChatSubscribed: result}, + }); err != nil { + sub.Cleanup() + c.chatStreamsMu.Lock() + // Cleanup 幂等:仅当仍指向本次订阅时移除登记。 + delete(c.chatStreams, conversationID) + c.chatStreamsMu.Unlock() + // 被掉帧的订阅响应会让客户端干等到超时且无人重订阅;控制队列上的重置信号重新武装其恢复循环。 + if errors.Is(err, wscore.ErrWriteQueueFull) { + c.sendSubscriptionResetOrClose(conversationID) + } + return + } + + go c.forwardConversationEvents(conversationID, sub) +} + +// handleChatUnsubscribe 对应 v1 "chat.unsubscribe"。 +func (c *browserConn) handleChatUnsubscribe(requestID string, req *gatewayv2.ChatUnsubscribeRequest) { + conversationID := strings.TrimSpace(req.GetConversationId()) + + c.chatStreamsMu.Lock() + if cancel := c.chatStreams[conversationID]; cancel != nil { + cancel() + delete(c.chatStreams, conversationID) + } + c.chatStreamsMu.Unlock() + + _ = c.sendAck(requestID) +} + +func (c *browserConn) sendAck(requestID string) error { + return c.send(wscore.FrameResponse, "ack", &gatewayv2.WebServerFrame{ + RequestId: requestID, + Payload: &gatewayv2.WebServerFrame_Ack{Ack: &gatewayv2.AckResult{Ok: true}}, + }) +} + +// forwardConversationEvents 推送订阅后的实时会话事件;订阅通道溢出或写队列持续拥塞时 +// 通知客户端重订阅(after_seq 从缓冲重放缺口),拥塞只牺牲该订阅、不牺牲连接。 +func (c *browserConn) forwardConversationEvents( + conversationID string, + sub *session.ConversationSubscription, +) { + defer sub.Cleanup() + for { + select { + case <-c.done: + return + case event, ok := <-sub.EventCh: + if !ok { + if sub.Overflowed() { + c.sendSubscriptionResetOrClose(conversationID) + } + return + } + payload, err := json.Marshal(event.Payload) + if err != nil { + continue + } + if err := c.send(wscore.FrameData, "chat_event", &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_ChatEvent{ + ChatEvent: &gatewayv2.ChatStreamEvent{ + ConversationId: conversationID, + Seq: event.Seq, + PayloadJson: payload, + }, + }, + }); err != nil { + if errors.Is(err, wscore.ErrWriteQueueFull) { + // 重置帧走控制队列越过拥塞积压;客户端重同步后按 seq 去重在途旧事件。 + c.sendSubscriptionResetOrClose(conversationID) + } + return + } + } + } +} + +// sendSubscriptionResetOrClose 送出恢复被掉订阅的唯一信号;连控制队列都容不下时关闭连接, +// 重连后的重订阅(after_seq)是仅剩的不可丢路径。 +func (c *browserConn) sendSubscriptionResetOrClose(conversationID string) { + if err := c.send(wscore.FrameControl, "chat_subscription_reset", &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_ChatSubscriptionReset{ + ChatSubscriptionReset: &gatewayv2.ChatSubscriptionReset{ConversationId: conversationID}, + }, + }); err != nil { + c.core.Close() + } +} + +// --------------------------------------------------------------------------- +// workspace 活动订阅 +// --------------------------------------------------------------------------- + +// handleWorkspaceSubscribe 对应 v1 "workspace.subscribe"(读循环内联)。 +func (c *browserConn) handleWorkspaceSubscribe(requestID string, req *gatewayv2.WorkspaceSubscribeRequest) { + workdir := strings.TrimSpace(req.GetWorkdir()) + if workdir == "" { + _ = c.sendLocalError(requestID, "workdir is required") + return + } + + events, cancel := c.sm.SubscribeWorkspaceActivity(workdir) + sub := &workspaceSubscription{ + cancel: cancel, + done: make(chan struct{}), + } + + c.workspaceSubsMu.Lock() + if c.workspaceSubs == nil { + c.workspaceSubs = make(map[string]*workspaceSubscription) + } + if previous := c.workspaceSubs[workdir]; previous != nil { + previous.close() + } + c.workspaceSubs[workdir] = sub + c.workspaceSubsMu.Unlock() + + if err := c.sendAck(requestID); err != nil { + sub.close() + c.workspaceSubsMu.Lock() + if c.workspaceSubs[workdir] == sub { + delete(c.workspaceSubs, workdir) + } + c.workspaceSubsMu.Unlock() + return + } + + go func() { + for { + select { + case <-c.done: + return + case <-sub.done: + return + case event, ok := <-events: + if !ok { + return + } + if err := c.send(wscore.FrameData, "workspace_activity", &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_WorkspaceActivity{WorkspaceActivity: event}, + }); err != nil { + if errors.Is(err, wscore.ErrWriteQueueFull) { + continue + } + return + } + } + } + }() +} + +// handleWorkspaceUnsubscribe 对应 v1 "workspace.unsubscribe"。 +func (c *browserConn) handleWorkspaceUnsubscribe(requestID string, req *gatewayv2.WorkspaceUnsubscribeRequest) { + workdir := strings.TrimSpace(req.GetWorkdir()) + + c.workspaceSubsMu.Lock() + if sub := c.workspaceSubs[workdir]; sub != nil { + sub.close() + delete(c.workspaceSubs, workdir) + } + c.workspaceSubsMu.Unlock() + + _ = c.sendAck(requestID) +} + +// --------------------------------------------------------------------------- +// 广播事件扇出与快照回放 +// --------------------------------------------------------------------------- + +// startEventForwarders 启动九路广播转发(对应 v1 的 startXxxForwarder 组); +// 泛型 forward 统一可掉帧广播骨架,各路只提供订阅与帧构造。 +func (c *browserConn) startEventForwarders() { + forward(c, c.sm.SubscribeHistorySync, func(event *gatewayv1.HistorySyncEvent) (*gatewayv2.WebServerFrame, bool) { + return &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_HistoryEvent{HistoryEvent: event}, + }, true + }, "history_event") + + forward(c, c.sm.SubscribeSettingsSync, func(event *gatewayv1.SettingsSyncEvent) (*gatewayv2.WebServerFrame, bool) { + return &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_SettingsEvent{SettingsEvent: event}, + }, true + }, "settings_event") + + forward(c, c.sm.SubscribeTerminalEvents, func(event *gatewayv1.TerminalEvent) (*gatewayv2.WebServerFrame, bool) { + if !shared.TerminalEventAllowed(c.sm, event) || !c.terminalInterest.ShouldForward(event) { + return nil, false + } + return &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_TerminalEvent{TerminalEvent: event}, + }, true + }, "terminal_event") + + forward(c, c.sm.SubscribeSftpEvents, func(event *gatewayv1.SftpEvent) (*gatewayv2.WebServerFrame, bool) { + if !c.sm.WebSshTerminalEnabled() { + return nil, false + } + return &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_SftpEvent{SftpEvent: event}, + }, true + }, "sftp_event") + + forward(c, c.sm.SubscribeChatQueueEvents, func(event *gatewayv1.ChatQueueEvent) (*gatewayv2.WebServerFrame, bool) { + return &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_ChatQueueEvent{ChatQueueEvent: event}, + }, true + }, "chat_queue_event") + + forward(c, c.sm.SubscribeChatActivity, func(event session.ConversationActivityEvent) (*gatewayv2.WebServerFrame, bool) { + return &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_ChatActivity{ChatActivity: chatActivityEvent(event)}, + }, true + }, "chat_activity") + + forward(c, c.sm.SubscribeTunnelState, func(event *gatewayv1.TunnelStateSnapshot) (*gatewayv2.WebServerFrame, bool) { + return &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_TunnelState{TunnelState: event}, + }, true + }, "tunnel_state") + + forward(c, c.sm.SubscribeManagedProcessState, func(event *gatewayv1.ManagedProcessSnapshot) (*gatewayv2.WebServerFrame, bool) { + return &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_ProcessState{ProcessState: event}, + }, true + }, "process_state") + + forward(c, c.sm.SubscribeStatus, func(status session.Status) (*gatewayv2.WebServerFrame, bool) { + return &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_Status{Status: statusEvent(status)}, + }, true + }, "status") +} + +// forward 是可掉帧广播转发的共用骨架:subscribe 建立订阅(cleanup 随 goroutine 退出执行), +// build 过滤并构造帧;掉帧跳过继续,其他写错误结束转发。 +func forward[T any]( + c *browserConn, + subscribe func() (<-chan T, func()), + build func(T) (*gatewayv2.WebServerFrame, bool), + kind string, +) { + events, cleanup := subscribe() + go func() { + defer cleanup() + for { + select { + case <-c.done: + return + case event, ok := <-events: + if !ok { + return + } + frame, send := build(event) + if !send { + continue + } + if err := c.send(wscore.FrameData, kind, frame); err != nil { + if errors.Is(err, wscore.ErrWriteQueueFull) { + continue + } + return + } + } + } + }() +} + +// replaySnapshots 在鉴权后把当前状态画到新连接上(与 v1 四组回放一致),免去首轮轮询。 +func (c *browserConn) replaySnapshots() { + // 终端会话快照:以 created 事件逐条回放。 + if shared.TerminalFeaturesEnabled(c.sm) { + for _, terminalSession := range c.sm.TerminalSessionSnapshot("") { + if !shared.TerminalSessionAllowed(c.sm, terminalSession) { + continue + } + if err := c.send(wscore.FrameData, "terminal_event", &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_TerminalEvent{ + TerminalEvent: &gatewayv1.TerminalEvent{ + Kind: "created", + SessionId: terminalSession.GetId(), + ProjectPathKey: terminalSession.GetProjectPathKey(), + Session: terminalSession, + }, + }, + }); err != nil { + return + } + } + } + + // 隧道/进程快照:空快照发空消息(等价 v1 默认空 payload——revision 0、无条目), + // 保证客户端拿到确定的初始状态。 + tunnelSnapshot := c.sm.TunnelStateSnapshot() + if tunnelSnapshot == nil { + tunnelSnapshot = &gatewayv1.TunnelStateSnapshot{} + } + _ = c.send(wscore.FrameData, "tunnel_state", &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_TunnelState{TunnelState: tunnelSnapshot}, + }) + processSnapshot := c.sm.ManagedProcessSnapshotCached() + if processSnapshot == nil { + processSnapshot = &gatewayv1.ManagedProcessSnapshot{} + } + _ = c.send(wscore.FrameData, "process_state", &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_ProcessState{ProcessState: processSnapshot}, + }) + _ = c.send(wscore.FrameData, "status", &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_Status{Status: statusEvent(c.sm.Status())}, + }) +} diff --git a/crates/agent-gateway/internal/protocol/pbws/browser_local.go b/crates/agent-gateway/internal/protocol/pbws/browser_local.go new file mode 100644 index 000000000..924d2828a --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/browser_local.go @@ -0,0 +1,255 @@ +package pbws + +import ( + "context" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/liveagent/agent-gateway/internal/chatcmd" + "github.com/liveagent/agent-gateway/internal/config" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/session" + "github.com/liveagent/agent-gateway/internal/transport/wscore" +) + +// 由网关状态直接应答(或由网关编排)的本地操作,与 v1 各处理器逐一对应;chat 编排复用 internal/chatcmd。 + +// handleStatusGet 对应 v1 "status.get"。 +func (c *browserConn) handleStatusGet(requestID string) { + _ = c.send(wscore.FrameResponse, "status", &gatewayv2.WebServerFrame{ + RequestId: requestID, + Payload: &gatewayv2.WebServerFrame_Status{ + Status: statusEvent(c.sm.Status()), + }, + }) +} + +// handleChatPrepare 对应 v1 "chat.prepare":探活/唤醒桌面运行时后返回与 status_get +// 同构的状态(新旧客户端共享一个状态归一化器)。 +func (c *browserConn) handleChatPrepare(requestID string, _ *gatewayv2.ChatPrepareRequest) { + ctx, cancel := context.WithTimeout(context.Background(), chatcmd.PrepareTimeout(c.cfg)) + defer cancel() + if err := chatcmd.ProbeRuntime(ctx, c.sm); err != nil { + _ = c.sendLocalError(requestID, errorMessage(err)) + return + } + // 与 v1 的 writePriorityResponse 等价:走控制队列,避免被数据积压饿死。 + _ = c.send(wscore.FrameControl, "status", &gatewayv2.WebServerFrame{ + RequestId: requestID, + Payload: &gatewayv2.WebServerFrame_Status{ + Status: statusEvent(c.sm.Status()), + }, + }) +} + +// handleChatActivities 对应 v1 "chat.activities":仅由网关状态应答,桌面端离线时亦可用。 +func (c *browserConn) handleChatActivities(requestID string) { + activities := c.sm.ActiveConversationActivities() + running := make([]*gatewayv2.ChatRunActivity, 0, len(activities)) + for _, activity := range activities { + running = append(running, chatRunActivityListItem(activity)) + } + _ = c.send(wscore.FrameResponse, "chat_activities", &gatewayv2.WebServerFrame{ + RequestId: requestID, + Payload: &gatewayv2.WebServerFrame_ChatActivities{ + ChatActivities: &gatewayv2.ChatActivitiesResult{RunningConversations: running}, + }, + }) +} + +// handleChatCommand 对应 v1 "chat.command":submit / edit_resend 经网关编排 +// (去重、接受即回执、命令更新观察、启动看门狗、投递),cancel 单独处理。 +func (c *browserConn) handleChatCommand(requestID string, cmd *gatewayv1.ChatCommandRequest) { + commandType := strings.TrimSpace(cmd.GetType()) + body := chatcmd.RequestBodyFromProto(cmd.GetRequest()) + baseMessageRef := chatcmd.MessageRefFromProto(cmd.GetBaseMessageRef()) + + switch commandType { + case "chat.submit": + baseMessageRef = nil + case "chat.edit_resend": + if baseMessageRef == nil { + _ = c.sendLocalError(requestID, "base_message_ref is required") + return + } + if err := chatcmd.ValidateMessageRef(baseMessageRef); err != nil { + _ = c.sendLocalError(requestID, err.Error()) + return + } + case "chat.cancel": + c.handleChatCancel(requestID, cmd.GetCancel()) + return + default: + _ = c.sendLocalError(requestID, "unsupported chat command") + return + } + + if err := chatcmd.NormalizeRequestBody(&body); err != nil { + _ = c.sendLocalError(requestID, err.Error()) + return + } + + if existing, ok := c.sm.LookupChatCommand(body.ClientRequestID); ok { + c.respondChatCommandDeduped(requestID, existing) + return + } + + if !c.sm.IsOnline() { + _ = c.sendLocalError(requestID, "agent offline") + return + } + probeCtx, probeCancel := context.WithTimeout( + context.Background(), chatcmd.PrepareTimeout(c.cfg), + ) + probeErr := chatcmd.ProbeRuntimeForCommand(probeCtx, c.sm) + probeCancel() + if probeErr != nil { + _ = c.sendLocalError(requestID, errorMessage(probeErr)) + return + } + + runID := "chat-command-" + uuid.NewString() + start := c.sm.StartChatCommand( + runID, + body.ConversationID, + body.Workdir, + body.ClientRequestID, + chatcmd.BuildAcceptedCommandPayloads(body, baseMessageRef), + ) + if start.Deduped { + c.respondChatCommandDeduped(requestID, start) + return + } + updates, cleanupWatch := c.sm.WatchChatCommand(start.RunID) + + _ = c.sendChatCommandAccepted(requestID, start) + + go c.forwardChatCommandUpdates(updates, cleanupWatch) + go chatcmd.DispatchAcceptedCommand( + context.Background(), c.cfg, c.sm, cleanupWatch, start, body, baseMessageRef, chatcmd.NewTraceID(), + ) +} + +// respondChatCommandDeduped 用既有运行应答重复的 client_request_id 并转发其(回放的) +// 前置阶段更新;观察流由看门狗窗口兜底关闭。 +func (c *browserConn) respondChatCommandDeduped(requestID string, start session.ChatCommandStart) { + updates, cleanupWatch := c.sm.WatchChatCommand(start.RunID) + _ = c.sendChatCommandAccepted(requestID, start) + go c.forwardChatCommandUpdates(updates, cleanupWatch) + cleanupChatCommandWatchAfter(c.cfg, cleanupWatch) +} + +func (c *browserConn) sendChatCommandAccepted(requestID string, start session.ChatCommandStart) error { + // 与 v1 的优先响应等价:接受回执延迟敏感,走控制队列。 + return c.send(wscore.FrameControl, "chat_accepted", &gatewayv2.WebServerFrame{ + RequestId: requestID, + Payload: &gatewayv2.WebServerFrame_ChatAccepted{ + ChatAccepted: &gatewayv2.ChatCommandAccepted{ + RunId: start.RunID, + ConversationId: start.ConversationID, + AcceptedSeq: start.AcceptedSeq, + Deduped: start.Deduped, + }, + }, + }) +} + +// forwardChatCommandUpdates 把前置阶段结果(bound / queued_in_gui / failed)推给 +// 发起命令的连接(走控制队列)。 +func (c *browserConn) forwardChatCommandUpdates( + updates <-chan session.ChatCommandUpdate, + cleanup func(), +) { + if cleanup != nil { + defer cleanup() + } + for { + select { + case <-c.done: + return + case update, ok := <-updates: + if !ok { + return + } + if err := c.send(wscore.FrameControl, "chat_command_update", &gatewayv2.WebServerFrame{ + Payload: &gatewayv2.WebServerFrame_ChatCommandUpdate{ + ChatCommandUpdate: chatCommandUpdate(update), + }, + }); err != nil { + return + } + } + } +} + +// cleanupChatCommandWatchAfter 为去重提交的更新观察流设兜底关闭窗口 +// (与 v1 同:AfterFunc 不占 goroutine,cleanup 幂等)。 +func cleanupChatCommandWatchAfter(cfg *config.Config, cleanup func()) { + if cleanup == nil { + return + } + timeout := chatcmd.StartTimeout(cfg) + chatcmd.RenderStartTimeout(cfg) + if timeout <= 0 { + timeout = 15 * time.Second + } + time.AfterFunc(timeout, cleanup) +} + +const chatCancelWatchdogTimeout = 15 * time.Second + +// handleChatCancel 对应 v1 "chat.cancel"。 +func (c *browserConn) handleChatCancel(requestID string, cancelReq *gatewayv1.CancelChatRequest) { + conversationID := strings.TrimSpace(cancelReq.GetConversationId()) + if conversationID == "" { + _ = c.sendLocalError(requestID, "conversation_id is required") + return + } + if !c.sm.IsOnline() { + _ = c.sendLocalError(requestID, "agent offline") + return + } + + // 不终结运行:活动状态翻为 cancelling,以桌面端终态信号为准,超时由看门狗强制收尾。 + runID, active := c.sm.MarkConversationCancelling(conversationID, strings.TrimSpace(cancelReq.GetRunId())) + if !active { + _ = c.sendChatCancelResult(requestID, true, "", conversationID) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), c.srv.writeTimeout()) + defer cancel() + + if err := c.sm.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ + RequestId: runID, + Timestamp: time.Now().Unix(), + Payload: chatcmd.BuildCancelCommandPayload(conversationID), + }); err != nil { + _ = c.sendLocalError(requestID, errorMessage(err)) + return + } + + go watchChatCancel(c.sm, runID) + _ = c.sendChatCancelResult(requestID, true, runID, conversationID) +} + +func (c *browserConn) sendChatCancelResult(requestID string, ok bool, runID, conversationID string) error { + return c.send(wscore.FrameResponse, "chat_cancelled", &gatewayv2.WebServerFrame{ + RequestId: requestID, + Payload: &gatewayv2.WebServerFrame_ChatCancelled{ + ChatCancelled: &gatewayv2.ChatCancelResult{ + Ok: ok, + RunId: runID, + ConversationId: conversationID, + }, + }, + }) +} + +func watchChatCancel(sm *session.Manager, runID string) { + time.Sleep(chatCancelWatchdogTimeout) + sm.ForceFinishRun(runID, "cancelled", "cancel_timeout", + "The desktop runtime did not confirm the cancellation in time.") +} diff --git a/crates/agent-gateway/internal/protocol/pbws/browser_relay.go b/crates/agent-gateway/internal/protocol/pbws/browser_relay.go new file mode 100644 index 000000000..c6da61fa2 --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/browser_relay.go @@ -0,0 +1,72 @@ +package pbws + +import ( + "context" + "strings" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/protocol/shared" + "github.com/liveagent/agent-gateway/internal/transport/wscore" +) + +// handleAgentRequest 直通转发一条浏览器构造的 GatewayEnvelope:白名单/限额校验 → +// request_id 按连接命名空间化 → 经 session 层等待关联响应 → list 类共享后处理 → +// 还原 request_id 回送。取代 v1 约 90 个手工编解码处理器,载荷零翻译。 +func (c *browserConn) handleAgentRequest(requestID string, env *gatewayv1.GatewayEnvelope) { + if requestID == "" { + _ = c.sendLocalError(requestID, "request id is required") + return + } + if err := vetAgentRequest(c.sm, env); err != nil { + _ = c.sendLocalError(requestID, err.Error()) + return + } + + // 命名空间化:多标签页共享一个桌面端,透传 id 必须按连接隔离;回程剥离前缀还原。 + agentRequestID := c.idPrefix + requestID + env.RequestId = agentRequestID + if env.GetTimestamp() == 0 { + env.Timestamp = time.Now().Unix() + } + + ctx, cancel := context.WithTimeout(context.Background(), c.srv.requestTimeout()) + defer cancel() + go func() { + select { + case <-c.done: + cancel() + case <-ctx.Done(): + } + }() + + response, err := c.sm.AwaitUnaryResponse(ctx, agentRequestID, env) + if err != nil { + _ = c.sendLocalError(requestID, errorMessage(err)) + return + } + + // list 类终端响应的合并/过滤与兴趣登记(与 v1 同一份域逻辑)。 + if terminalResp := response.GetTerminalResponse(); terminalResp != nil { + req := env.GetTerminalRequest() + finalized := shared.FinalizeTerminalResponse( + c.sm, + c.terminalInterest, + strings.TrimSpace(req.GetAction()), + strings.TrimSpace(req.GetProjectPathKey()), + terminalResp, + ) + if finalized != terminalResp { + response.Payload = &gatewayv1.AgentEnvelope_TerminalResponse{TerminalResponse: finalized} + } + } + + // 还原关联 id 后原样回送(含 error=99 臂:错误语义交由客户端处理,与 v1 + // writeError(errResp.Message) 等价但保留结构化错误码)。 + response.RequestId = requestID + _ = c.send(wscore.FrameResponse, "agent_response", &gatewayv2.WebServerFrame{ + RequestId: requestID, + Payload: &gatewayv2.WebServerFrame_AgentResponse{AgentResponse: response}, + }) +} diff --git a/crates/agent-gateway/internal/protocol/pbws/guard.go b/crates/agent-gateway/internal/protocol/pbws/guard.go new file mode 100644 index 000000000..f9f0be621 --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/guard.go @@ -0,0 +1,115 @@ +package pbws + +import ( + "errors" + "strings" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/protocol/shared" + "github.com/liveagent/agent-gateway/internal/session" +) + +// 直通白名单与限额校验:v1 路由表天然充当的"浏览器可发起哪些操作"白名单,在 v2 信封直通后 +// 由本文件承接——未列入白名单的载荷臂(内部推送臂、须走网关编排的 chat_command、ping 等) +// 一律拒绝;功能开关门控与字段限额在转发前施加;list 类响应后处理经 finalize 钩子执行。 + +const ( + maxHistoryListLimit = 200 + defaultHistoryListPage = 1 + defaultHistoryListPageSize = 80 +) + +// vetAgentRequest 校验并(必要时)原地修正一条直通请求;返回错误则拒绝转发,错误信息面向客户端。 +func vetAgentRequest(sm *session.Manager, env *gatewayv1.GatewayEnvelope) error { + switch payload := env.GetPayload().(type) { + case nil: + return errors.New("agent_request payload is required") + + // ---- 普通直通臂(无门控) ---- + case *gatewayv1.GatewayEnvelope_HistoryList: + clampHistoryList(payload.HistoryList) + return nil + case *gatewayv1.GatewayEnvelope_HistoryGet, + *gatewayv1.GatewayEnvelope_HistoryRename, + *gatewayv1.GatewayEnvelope_HistoryDelete, + *gatewayv1.GatewayEnvelope_HistoryPrefix, + *gatewayv1.GatewayEnvelope_HistoryPin, + *gatewayv1.GatewayEnvelope_HistoryShareGet, + *gatewayv1.GatewayEnvelope_HistoryShareSet, + *gatewayv1.GatewayEnvelope_HistoryWorkdirs, + *gatewayv1.GatewayEnvelope_HistoryBranch, + *gatewayv1.GatewayEnvelope_ProviderList, + *gatewayv1.GatewayEnvelope_ProviderModels, + *gatewayv1.GatewayEnvelope_SettingsGet, + *gatewayv1.GatewayEnvelope_SettingsUpdate, + *gatewayv1.GatewayEnvelope_SettingsResetSshKnownHost, + *gatewayv1.GatewayEnvelope_SkillFilesList, + *gatewayv1.GatewayEnvelope_SkillMetadataRead, + *gatewayv1.GatewayEnvelope_SkillTextRead, + *gatewayv1.GatewayEnvelope_SkillManage, + *gatewayv1.GatewayEnvelope_FileMentionList, + *gatewayv1.GatewayEnvelope_UploadedImagePreview, + *gatewayv1.GatewayEnvelope_MemoryManage, + *gatewayv1.GatewayEnvelope_CronManage, + *gatewayv1.GatewayEnvelope_FsRoots, + *gatewayv1.GatewayEnvelope_FsListDirs, + *gatewayv1.GatewayEnvelope_FsCreateProjectFolder, + *gatewayv1.GatewayEnvelope_FsList, + *gatewayv1.GatewayEnvelope_FsWriteText, + *gatewayv1.GatewayEnvelope_FsCreateDir, + *gatewayv1.GatewayEnvelope_FsRename, + *gatewayv1.GatewayEnvelope_FsDelete, + *gatewayv1.GatewayEnvelope_FsReadEditableText, + *gatewayv1.GatewayEnvelope_FsReadWorkspaceImage, + *gatewayv1.GatewayEnvelope_GitRequest, + *gatewayv1.GatewayEnvelope_ChatQueue: + return nil + + // ---- 带功能门控 / 限额的直通臂 ---- + case *gatewayv1.GatewayEnvelope_TerminalRequest: + req := payload.TerminalRequest + action := strings.TrimSpace(req.GetAction()) + if !shared.TerminalRequestAllowed(sm, action, strings.TrimSpace(req.GetSessionId())) { + return errors.New(shared.TerminalPermissionError(action)) + } + return nil + case *gatewayv1.GatewayEnvelope_SftpRequest: + if !sm.WebSshTerminalEnabled() { + return errors.New("web SSH SFTP is disabled in desktop Remote settings") + } + return nil + case *gatewayv1.GatewayEnvelope_TunnelMutation: + if !sm.WebTunnelsEnabled() { + return errors.New("web tunnels are disabled in desktop Remote settings") + } + return nil + case *gatewayv1.GatewayEnvelope_ManagedProcessRequest: + req := payload.ManagedProcessRequest + action := strings.TrimSpace(req.GetAction()) + if strings.TrimSpace(req.GetProcessId()) == "" && action != "clear" && action != "snapshot" { + return errors.New("process_id is required") + } + return nil + + // ---- 明确拒绝的臂 ---- + default: + // 含 chat_command(须走网关编排)、ping(探活由网关发起)、upload_readable_files + // (走 HTTP 上传)、history_share_resolve(公共分享端点专用)及网关内部推送臂。 + return errors.New("unsupported agent_request payload") + } +} + +// clampHistoryList 施加与 v1 相同的分页默认值与上限。 +func clampHistoryList(req *gatewayv1.HistoryListRequest) { + if req == nil { + return + } + if req.GetPage() <= 0 { + req.Page = defaultHistoryListPage + } + if req.GetPageSize() <= 0 { + req.PageSize = defaultHistoryListPageSize + } else if req.GetPageSize() > maxHistoryListLimit { + req.PageSize = maxHistoryListLimit + } +} diff --git a/crates/agent-gateway/internal/protocol/pbws/handshake.go b/crates/agent-gateway/internal/protocol/pbws/handshake.go new file mode 100644 index 000000000..696796f63 --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/handshake.go @@ -0,0 +1,59 @@ +package pbws + +import ( + "strings" + "time" + + "github.com/gorilla/websocket" + + "github.com/liveagent/agent-gateway/internal/auth" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" +) + +// helloVerdict 是握手校验结果;ok=false 时 message 面向客户端。 +type helloVerdict struct { + ok bool + message string +} + +// vetHello 校验 ClientHello 的协议版本、角色与令牌(常量时间比较)。 +func (s *Server) vetHello(hello *gatewayv2.ClientHello, wantRole gatewayv2.ClientRole) helloVerdict { + if hello == nil { + return helloVerdict{message: "hello frame is required"} + } + if hello.GetProtocolVersion() != ProtocolVersion { + return helloVerdict{message: "unsupported protocol version"} + } + role := hello.GetRole() + // hello 缺省角色按端点预期补齐(路径已可区分);显式错误角色拒绝,防止 agent 帧被当浏览器帧处理。 + if role != gatewayv2.ClientRole_CLIENT_ROLE_UNSPECIFIED && role != wantRole { + return helloVerdict{message: "unexpected client role"} + } + if !auth.ValidateToken(hello.GetToken(), s.cfg.Token) { + return helloVerdict{message: "unauthorized"} + } + return helloVerdict{ok: true} +} + +// serverHello 构造握手应答;sessionID 仅 agent 角色使用。 +func (s *Server) serverHello(ok bool, message string, sessionID string) *gatewayv2.ServerHello { + return &gatewayv2.ServerHello{ + Ok: ok, + Message: strings.TrimSpace(message), + SessionId: strings.TrimSpace(sessionID), + ServerTime: time.Now().Unix(), + HeartbeatPeriodSeconds: uint32(s.heartbeatPeriod() / time.Second), + MaxMessageBytes: uint64(s.readLimit()), + } +} + +// closeUnauthorized 以鉴权失败码关闭连接(调用方已写出失败 hello)。 +func closeUnauthorized(conn *websocket.Conn, timeout time.Duration) { + deadline := time.Now().Add(timeout) + _ = conn.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(closeCodeUnauthorized, "unauthorized"), + deadline, + ) + _ = conn.Close() +} diff --git a/crates/agent-gateway/internal/protocol/pbws/seam.go b/crates/agent-gateway/internal/protocol/pbws/seam.go new file mode 100644 index 000000000..9077bbe27 --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/seam.go @@ -0,0 +1,92 @@ +package pbws + +import ( + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/session" +) + +// session 层 Go seam 类型到 v2 proto 消息的映射;字段与 v1 JSON 线格式一一对应, +// 保证两代协议对同一状态的表述一致。 + +// statusEvent 映射 session.Status。 +func statusEvent(status session.Status) *gatewayv2.StatusEvent { + return &gatewayv2.StatusEvent{ + Online: status.Online, + AgentReady: status.AgentReady, + ChatRuntimeReady: status.ChatRuntimeReady, + AgentId: status.AgentID, + AgentVersion: status.AgentVersion, + SessionId: status.SessionID, + ConnectedSince: status.ConnectedSince, + LastHeartbeat: status.LastHeartbeat, + RuntimeState: status.RuntimeState, + RuntimeLastHeartbeat: status.RuntimeLastHeartbeat, + RuntimeWorkerId: status.RuntimeWorkerID, + RuntimeVisible: status.RuntimeVisible, + RuntimeActiveRunCount: status.RuntimeActiveRunCount, + } +} + +// chatActivityEvent 映射 session.ConversationActivityEvent。 +func chatActivityEvent(event session.ConversationActivityEvent) *gatewayv2.ChatActivityEvent { + return &gatewayv2.ChatActivityEvent{ + ConversationId: event.ConversationID, + RunId: event.RunID, + ClientRequestId: event.ClientRequestID, + Running: event.Running, + State: event.State, + Workdir: event.Workdir, + UpdatedAtMs: event.UpdatedAt.UnixMilli(), + } +} + +// chatRunActivity 映射 session.RunActivity(空值字段语义与 v1 手写 map 一致)。 +func chatRunActivity(activity *session.RunActivity) *gatewayv2.ChatRunActivity { + if activity == nil { + return nil + } + return &gatewayv2.ChatRunActivity{ + RunId: activity.RunID, + State: activity.State, + StartedSeq: activity.StartedSeq, + UpdatedAtMs: activity.UpdatedAt.UnixMilli(), + ToolStatus: activity.ToolStatus, + ToolStatusIsCompaction: activity.ToolStatusIsCompaction, + ClientRequestId: activity.ClientRequestID, + } +} + +// chatRunActivityListItem 映射运行中会话列表项(含会话与工作目录)。 +func chatRunActivityListItem(activity session.RunActivity) *gatewayv2.ChatRunActivity { + item := chatRunActivity(&activity) + item.ConversationId = activity.ConversationID + item.Workdir = activity.Workdir + return item +} + +// chatRunSnapshot 映射 session.RunSnapshot。 +func chatRunSnapshot(snapshot *session.RunSnapshot) *gatewayv2.ChatRunSnapshot { + if snapshot == nil { + return nil + } + return &gatewayv2.ChatRunSnapshot{ + RunId: snapshot.RunID, + Revision: snapshot.Revision, + EntriesJson: snapshot.EntriesJSON, + ToolStatus: snapshot.ToolStatus, + ToolStatusIsCompaction: snapshot.ToolStatusIsCompaction, + AsOfSeq: snapshot.AsOfSeq, + } +} + +// chatCommandUpdate 映射 session.ChatCommandUpdate。 +func chatCommandUpdate(update session.ChatCommandUpdate) *gatewayv2.ChatCommandUpdate { + return &gatewayv2.ChatCommandUpdate{ + RunId: update.RunID, + ClientRequestId: update.ClientRequestID, + ConversationId: update.ConversationID, + Phase: update.Phase, + ErrorCode: update.ErrorCode, + Message: update.Message, + } +} diff --git a/crates/agent-gateway/internal/protocol/pbws/server.go b/crates/agent-gateway/internal/protocol/pbws/server.go new file mode 100644 index 000000000..01a7f86c8 --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/server.go @@ -0,0 +1,111 @@ +// Package pbws 实现 v2 统一线协议(WebSocket+Protobuf)服务端的三条链路(见 proto/v2/gateway_ws.proto): +// /ws/v2 浏览器直通、/ws/v2/agent 桌面端(替代 v1 gRPC)、/ws/v2/terminal 终端数据面。 +// 本包只做帧编解码、鉴权握手、直通白名单与事件扇出;会话状态复用 session, +// 传输运行时复用 wscore,跨协议域逻辑复用 shared 与 chatcmd。 +package pbws + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" + + "github.com/liveagent/agent-gateway/internal/config" + "github.com/liveagent/agent-gateway/internal/protocol/shared" + "github.com/liveagent/agent-gateway/internal/session" +) + +// Subprotocol 是 v2 的 WebSocket 子协议名;服务端必须回显,否则浏览器主动断开握手。 +const Subprotocol = "liveagent.v2.pb" + +// ProtocolVersion 是本包实现的协议版本号(ClientHello.protocol_version)。 +const ProtocolVersion = 2 + +// closeCodeUnauthorized 是鉴权失败时的自定义关闭码(4000-4999 为应用保留段)。 +const closeCodeUnauthorized = 4401 + +// Server 聚合三条 v2 链路的依赖,由 http 路由层构造一次、复用于全部连接。 +type Server struct { + cfg *config.Config + sm *session.Manager +} + +// NewServer 构造 v2 协议服务端。 +func NewServer(cfg *config.Config, sm *session.Manager) *Server { + return &Server{cfg: cfg, sm: sm} +} + +func (s *Server) upgrader() websocket.Upgrader { + return websocket.Upgrader{ + Subprotocols: []string{Subprotocol}, + CheckOrigin: func(r *http.Request) bool { + return shared.OriginAllowed(r) + }, + } +} + +// readLimit 与 v1 保持一致:复用 gRPC 消息大小上限。 +func (s *Server) readLimit() int64 { + if s.cfg != nil && s.cfg.GRPCMaxMessageBytes > 0 { + return int64(s.cfg.GRPCMaxMessageBytes) + } + return int64(config.DefaultGRPCMaxMessageBytes) +} + +func (s *Server) heartbeatPeriod() time.Duration { + if s.cfg != nil && s.cfg.WebSocketHeartbeatPeriod > 0 { + return s.cfg.WebSocketHeartbeatPeriod + } + return 15 * time.Second +} + +func (s *Server) writeTimeout() time.Duration { + if s.cfg != nil && s.cfg.WebSocketWriteTimeout > 0 { + return s.cfg.WebSocketWriteTimeout + } + return 10 * time.Second +} + +func (s *Server) requestTimeout() time.Duration { + if s.cfg != nil && s.cfg.RequestTimeout > 0 { + return s.cfg.RequestTimeout + } + return 2 * time.Minute +} + +// errorMessage 把内部错误映射为对客户端友好的信息(与 v1 一致)。 +func errorMessage(err error) string { + if err == nil { + return "request failed" + } + if errors.Is(err, context.DeadlineExceeded) { + return "request timed out" + } + if errors.Is(err, context.Canceled) { + return "request canceled" + } + if errors.Is(err, session.ErrAgentOffline) { + return "agent offline" + } + return err.Error() +} + +// writeDirectMessage 在写泵启动前(握手阶段)直接写出一条二进制帧。 +func writeDirectMessage(conn *websocket.Conn, timeout time.Duration, msg proto.Message) error { + data, err := proto.Marshal(msg) + if err != nil { + return err + } + if timeout > 0 { + if err := conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil { + return err + } + defer func() { + _ = conn.SetWriteDeadline(time.Time{}) + }() + } + return conn.WriteMessage(websocket.BinaryMessage, data) +} diff --git a/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go b/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go new file mode 100644 index 000000000..c3b05c36c --- /dev/null +++ b/crates/agent-gateway/internal/protocol/pbws/terminal_conn.go @@ -0,0 +1,393 @@ +package pbws + +import ( + "context" + "errors" + "net/http" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" + + "github.com/liveagent/agent-gateway/internal/observability" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/protocol/shared" + "github.com/liveagent/agent-gateway/internal/session" +) + +// 终端数据面(/ws/v2/terminal):两端共用一条路径,角色由 hello 区分——浏览器角色对应 +// v1 /ws/terminal 自定义二进制流(attach/detach 登记、input/resize 需已附着、按订阅过滤), +// Agent 角色对应 v1 gRPC AgentTerminalConnect(登记到-agent 通道、广播入站帧)。 +// 帧载荷直接复用 proto TerminalStreamFrame,淘汰 v1 浏览器侧的手工帧格式。 + +const terminalWriteQueueSize = 1024 + +// TerminalHandler 返回 /ws/v2/terminal 的 HTTP 处理器。 +func (s *Server) TerminalHandler() http.Handler { + upgrader := s.upgrader() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + conn.SetReadLimit(s.readLimit()) + s.serveTerminal(conn) + }) +} + +func (s *Server) serveTerminal(conn *websocket.Conn) { + defer func() { _ = conn.Close() }() + + frame, ok := readTerminalFrame(conn) + if !ok { + return + } + hello := frame.GetHello() + // 终端路径两端共用:按 hello 声明的角色校验(未声明按浏览器处理)。 + wantRole := hello.GetRole() + if wantRole == gatewayv2.ClientRole_CLIENT_ROLE_UNSPECIFIED { + wantRole = gatewayv2.ClientRole_CLIENT_ROLE_BROWSER + } + verdict := s.vetHello(hello, wantRole) + if !verdict.ok { + _ = writeDirectMessage(conn, s.writeTimeout(), &gatewayv2.TerminalServerFrame{ + Payload: &gatewayv2.TerminalServerFrame_Hello{ + Hello: s.serverHello(false, verdict.message, ""), + }, + }) + closeUnauthorized(conn, s.writeTimeout()) + return + } + if err := writeDirectMessage(conn, s.writeTimeout(), &gatewayv2.TerminalServerFrame{ + Payload: &gatewayv2.TerminalServerFrame_Hello{ + Hello: s.serverHello(true, "", ""), + }, + }); err != nil { + return + } + + if wantRole == gatewayv2.ClientRole_CLIENT_ROLE_AGENT { + observability.Usage.V2TerminalConnectsTotal.Add(1) + s.serveTerminalAgent(conn) + return + } + observability.Usage.V2TerminalConnectsTotal.Add(1) + s.serveTerminalBrowser(conn) +} + +func readTerminalFrame(conn *websocket.Conn) (*gatewayv2.TerminalClientFrame, bool) { + for { + messageType, data, err := conn.ReadMessage() + if err != nil { + return nil, false + } + if messageType != websocket.BinaryMessage { + continue + } + var frame gatewayv2.TerminalClientFrame + if err := proto.Unmarshal(data, &frame); err != nil { + return nil, false + } + return &frame, true + } +} + +// --------------------------------------------------------------------------- +// Agent 角色(对应 v1 gRPC AgentTerminalConnect) +// --------------------------------------------------------------------------- + +func (s *Server) serveTerminalAgent(conn *websocket.Conn) { + toAgent := make(chan *gatewayv1.TerminalStreamFrame, 4096) + cleanup := s.sm.RegisterTerminalStreamToAgent(toAgent) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + <-ctx.Done() + _ = conn.Close() + }() + + go func() { + defer cancel() + for { + select { + case <-ctx.Done(): + return + case frame := <-toAgent: + if frame == nil { + continue + } + if !s.writeTerminalFrame(conn, frame) { + return + } + } + } + }() + + for { + frame, ok := readTerminalFrame(conn) + if !ok { + cancel() + return + } + if streamFrame := frame.GetFrame(); streamFrame != nil { + s.sm.BroadcastTerminalStreamFrame(streamFrame) + } + } +} + +func (s *Server) writeTerminalFrame(conn *websocket.Conn, frame *gatewayv1.TerminalStreamFrame) bool { + data, err := proto.Marshal(&gatewayv2.TerminalServerFrame{ + Payload: &gatewayv2.TerminalServerFrame_Frame{Frame: frame}, + }) + if err != nil { + return false + } + if timeout := s.writeTimeout(); timeout > 0 { + if err := conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil { + return false + } + defer func() { _ = conn.SetWriteDeadline(time.Time{}) }() + } + return conn.WriteMessage(websocket.BinaryMessage, data) == nil +} + +// --------------------------------------------------------------------------- +// 浏览器角色(对应 v1 /ws/terminal 二进制流) +// --------------------------------------------------------------------------- + +type terminalBrowserConn struct { + srv *Server + sm *session.Manager + conn *websocket.Conn + + out chan []byte + done chan struct{} + once sync.Once + + mu sync.RWMutex + attached map[string]struct{} + streams map[string]struct{} +} + +func (s *Server) serveTerminalBrowser(conn *websocket.Conn) { + c := &terminalBrowserConn{ + srv: s, + sm: s.sm, + conn: conn, + out: make(chan []byte, terminalWriteQueueSize), + done: make(chan struct{}), + attached: make(map[string]struct{}), + streams: make(map[string]struct{}), + } + defer c.close() + + go c.writeLoop() + c.startForwarder() + + for { + frame, ok := readTerminalFrame(conn) + if !ok { + return + } + streamFrame := frame.GetFrame() + if streamFrame == nil { + continue + } + c.handleFrame(streamFrame) + } +} + +func (c *terminalBrowserConn) handleFrame(frame *gatewayv1.TerminalStreamFrame) { + kind := strings.TrimSpace(frame.GetKind()) + if !c.frameAllowed(frame) { + c.enqueueFrame(terminalErrorFrame(frame, shared.TerminalPermissionError(kind))) + return + } + + switch kind { + case "attach": + c.remember(frame.GetSessionId(), frame.GetStreamId()) + case "detach": + c.forget(frame.GetSessionId(), frame.GetStreamId()) + case "input", "resize": + if !c.isAttached(frame.GetSessionId()) { + c.enqueueFrame(terminalErrorFrame(frame, "terminal stream is not attached")) + return + } + default: + c.enqueueFrame(terminalErrorFrame(frame, "unsupported terminal stream frame")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.sm.SendTerminalFrameToAgent(ctx, frame); err != nil { + message := "desktop agent is offline" + if !errors.Is(err, session.ErrAgentOffline) { + message = err.Error() + } + c.enqueueFrame(terminalErrorFrame(frame, message)) + } +} + +func (c *terminalBrowserConn) frameAllowed(frame *gatewayv1.TerminalStreamFrame) bool { + if frame == nil { + return false + } + sessionID := strings.TrimSpace(frame.GetSessionId()) + switch c.sm.TerminalSessionKind(sessionID) { + case "ssh": + return c.sm.WebSshTerminalEnabled() + case "local": + return c.sm.WebTerminalEnabled() + default: + return c.sm.WebTerminalEnabled() || c.sm.WebSshTerminalEnabled() + } +} + +func (c *terminalBrowserConn) startForwarder() { + frames, cleanup := c.sm.SubscribeTerminalStreamFrames() + go func() { + defer cleanup() + for { + select { + case <-c.done: + return + case frame, ok := <-frames: + if !ok { + c.close() + return + } + if !c.shouldForward(frame) { + continue + } + c.enqueueFrame(frame) + } + } + }() +} + +func (c *terminalBrowserConn) shouldForward(frame *gatewayv1.TerminalStreamFrame) bool { + if frame == nil { + return false + } + kind := strings.TrimSpace(frame.GetKind()) + if kind == "snapshot" || kind == "error" { + return c.knowsStream(frame.GetStreamId()) + } + if kind != "output" { + return false + } + return c.isAttached(frame.GetSessionId()) +} + +func (c *terminalBrowserConn) remember(sessionID string, streamID string) { + sessionID = strings.TrimSpace(sessionID) + streamID = strings.TrimSpace(streamID) + if sessionID == "" && streamID == "" { + return + } + c.mu.Lock() + if sessionID != "" { + c.attached[sessionID] = struct{}{} + } + if streamID != "" { + c.streams[streamID] = struct{}{} + } + c.mu.Unlock() +} + +func (c *terminalBrowserConn) forget(sessionID string, streamID string) { + sessionID = strings.TrimSpace(sessionID) + streamID = strings.TrimSpace(streamID) + if sessionID == "" && streamID == "" { + return + } + c.mu.Lock() + if sessionID != "" { + delete(c.attached, sessionID) + } + if streamID != "" { + delete(c.streams, streamID) + } + c.mu.Unlock() +} + +func (c *terminalBrowserConn) isAttached(sessionID string) bool { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + return false + } + c.mu.RLock() + _, ok := c.attached[sessionID] + c.mu.RUnlock() + return ok +} + +func (c *terminalBrowserConn) knowsStream(streamID string) bool { + streamID = strings.TrimSpace(streamID) + if streamID == "" { + return false + } + c.mu.RLock() + _, ok := c.streams[streamID] + c.mu.RUnlock() + return ok +} + +// enqueueFrame 与 v1 语义一致:队列满即关闭连接(终端输出无可容忍的丢帧语义, +// 客户端重连后 attach + snapshot 恢复)。 +func (c *terminalBrowserConn) enqueueFrame(frame *gatewayv1.TerminalStreamFrame) { + data, err := proto.Marshal(&gatewayv2.TerminalServerFrame{ + Payload: &gatewayv2.TerminalServerFrame_Frame{Frame: frame}, + }) + if err != nil { + return + } + select { + case <-c.done: + case c.out <- data: + default: + c.close() + } +} + +func (c *terminalBrowserConn) writeLoop() { + for { + select { + case <-c.done: + return + case payload := <-c.out: + if timeout := c.srv.writeTimeout(); timeout > 0 { + _ = c.conn.SetWriteDeadline(time.Now().Add(timeout)) + } + if err := c.conn.WriteMessage(websocket.BinaryMessage, payload); err != nil { + c.close() + return + } + _ = c.conn.SetWriteDeadline(time.Time{}) + } + } +} + +func (c *terminalBrowserConn) close() { + c.once.Do(func() { + close(c.done) + _ = c.conn.Close() + }) +} + +func terminalErrorFrame(source *gatewayv1.TerminalStreamFrame, message string) *gatewayv1.TerminalStreamFrame { + return &gatewayv1.TerminalStreamFrame{ + Kind: "error", + StreamId: strings.TrimSpace(source.GetStreamId()), + SessionId: strings.TrimSpace(source.GetSessionId()), + ProjectPathKey: strings.TrimSpace(source.GetProjectPathKey()), + Error: strings.TrimSpace(message), + } +} diff --git a/crates/agent-gateway/internal/server/http.go b/crates/agent-gateway/internal/server/http.go index e5f0c7d7a..ed7533299 100644 --- a/crates/agent-gateway/internal/server/http.go +++ b/crates/agent-gateway/internal/server/http.go @@ -17,6 +17,7 @@ import ( "github.com/liveagent/agent-gateway/internal/config" "github.com/liveagent/agent-gateway/internal/handler" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/protocol/pbws" "github.com/liveagent/agent-gateway/internal/session" ) @@ -33,6 +34,13 @@ func NewHTTPServer(cfg *config.Config, sm *session.Manager) http.Handler { webSocketServer.ServeHTTP(w, r) })) rootMux.Handle("/ws/terminal", terminalWebSocketServer) + + // v2 统一协议(WebSocket+Protobuf)三链路;v1 路由保留服务旧客户端。 + v2 := pbws.NewServer(cfg, sm) + rootMux.Handle("/ws/v2", v2.BrowserHandler()) + rootMux.Handle("/ws/v2/agent", v2.AgentHandler()) + rootMux.Handle("/ws/v2/terminal", v2.TerminalHandler()) + 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)) diff --git a/crates/agent-gateway/internal/server/websocket_payload_bench_test.go b/crates/agent-gateway/internal/server/websocket_payload_bench_test.go new file mode 100644 index 000000000..16bee6748 --- /dev/null +++ b/crates/agent-gateway/internal/server/websocket_payload_bench_test.go @@ -0,0 +1,92 @@ +package server + +// v1(protojson 塑形 + JSON 编码)与 v2(proto 直接编码)响应路径的微基准对照, +// 用同一份 history.list 载荷。运行: +// +// go test ./internal/server -bench BenchmarkResponseEncoding -benchmem -run '^$' + +import ( + "encoding/json" + "fmt" + "testing" + + "google.golang.org/protobuf/proto" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" +) + +func benchmarkHistoryListResponse() *gatewayv1.HistoryListResponse { + conversations := make([]*gatewayv1.ConversationSummary, 0, 50) + for i := 0; i < 50; i++ { + conversations = append(conversations, &gatewayv1.ConversationSummary{ + Id: fmt.Sprintf("conversation-%04d", i), + Title: fmt.Sprintf("对话标题 %d:关于 WebSocket 协议迁移的讨论", i), + MessageCount: int32(20 + i), + CreatedAt: 1_752_800_000 + int64(i)*3600, + UpdatedAt: 1_752_800_000 + int64(i)*7200, + ProviderId: "builtin-codex", + Model: "gpt-5.4-mini", + SessionId: fmt.Sprintf("session-%04d", i), + Cwd: "/home/user/projects/example-workspace", + IsPinned: i%7 == 0, + }) + } + return &gatewayv1.HistoryListResponse{ + Conversations: conversations, + TotalCount: 500, + } +} + +// BenchmarkResponseEncodingV1JSON 复刻 v1 出站路径:proto → 手工 map → JSON 信封编码。 +func BenchmarkResponseEncodingV1JSON(b *testing.B) { + resp := benchmarkHistoryListResponse() + b.ReportAllocs() + var lastSize int + for b.Loop() { + conversations := make([]map[string]any, 0, len(resp.GetConversations())) + for _, conversation := range resp.GetConversations() { + conversations = append(conversations, websocketConversationSummaryPayload(conversation)) + } + payload := map[string]any{ + "conversations": conversations, + "total_count": resp.GetTotalCount(), + } + data, err := json.Marshal(websocketEnvelope{ + ID: "bench-1", + Type: "response", + Payload: payload, + }) + if err != nil { + b.Fatal(err) + } + lastSize = len(data) + } + b.ReportMetric(float64(lastSize), "wire-bytes") +} + +// BenchmarkResponseEncodingV2Proto 为 v2 出站路径:信封装帧 + 一次 proto.Marshal。 +func BenchmarkResponseEncodingV2Proto(b *testing.B) { + resp := benchmarkHistoryListResponse() + b.ReportAllocs() + var lastSize int + for b.Loop() { + frame := &gatewayv2.WebServerFrame{ + RequestId: "bench-1", + Payload: &gatewayv2.WebServerFrame_AgentResponse{ + AgentResponse: &gatewayv1.AgentEnvelope{ + RequestId: "bench-1", + Payload: &gatewayv1.AgentEnvelope_HistoryListResp{ + HistoryListResp: resp, + }, + }, + }, + } + data, err := proto.Marshal(frame) + if err != nil { + b.Fatal(err) + } + lastSize = len(data) + } + b.ReportMetric(float64(lastSize), "wire-bytes") +} diff --git a/crates/agent-gateway/test/websocket/v2_chat_terminal_test.go b/crates/agent-gateway/test/websocket/v2_chat_terminal_test.go new file mode 100644 index 000000000..9fa38066b --- /dev/null +++ b/crates/agent-gateway/test/websocket/v2_chat_terminal_test.go @@ -0,0 +1,160 @@ +package websocket_test + +// v2 chat 命令编排与终端链路的集成测试。 + +import ( + "net/http" + "testing" + "time" + + "github.com/gorilla/websocket" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/protocol/pbws" + "github.com/liveagent/agent-gateway/internal/session" + "google.golang.org/protobuf/proto" +) + +// TestV2ChatCommandAcceptedFlow 覆盖 submit 编排:运行时探活(网关发 Ping、假 agent 回 Pong) +// → 接受回执 → 命令信封投递到 agent。 +func TestV2ChatCommandAcceptedFlow(t *testing.T) { + t.Parallel() + + sm, agentSession, conn, cleanup := newV2BrowserTest(t) + defer cleanup() + + sendProtoFrame(t, conn, &gatewayv2.WebClientFrame{ + RequestId: "cmd-1", + Payload: &gatewayv2.WebClientFrame_ChatCommand{ + ChatCommand: &gatewayv1.ChatCommandRequest{ + Type: "chat.submit", + Request: &gatewayv1.ChatRequest{ + ConversationId: "conv-cmd", + ClientRequestId: "client-cmd-1", + Message: "hello v2", + }, + }, + }, + }) + + // 网关先发运行时探活;假 agent 应答 Pong。 + answerChatRuntimeProbe(t, sm, agentSession) + + frame := receiveWebFrameWithID(t, conn, "cmd-1") + accepted := frame.GetChatAccepted() + if accepted == nil || accepted.GetConversationId() != "conv-cmd" || accepted.GetRunId() == "" { + t.Fatalf("chat command reply = %#v, want chat_accepted", frame) + } + + // 命令信封随后投递到 agent。 + outbound := readOutboundEnvelope(t, agentSession) + command := outbound.GetChatCommand() + if command.GetType() != "chat.submit" || command.GetRequest().GetMessage() != "hello v2" { + t.Fatalf("agent chat command = %#v, want chat.submit hello v2", command) + } +} + +// TestV2TerminalBrowserGating 覆盖终端链路浏览器角色:默认设置下 attach 被权限门控拒绝; +// 开启 Web 终端后 attach 转发失败(agent 离线)也以 error 帧回报。 +func TestV2TerminalBrowserGating(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + handler := pbws.NewServer(newV2TestConfig(), sm).TerminalHandler() + + dialTerminal := func() (*websocket.Conn, func()) { + conn, cleanup := dialV2(t, handler) + sendProtoFrame(t, conn, &gatewayv2.TerminalClientFrame{ + Payload: &gatewayv2.TerminalClientFrame_Hello{ + Hello: &gatewayv2.ClientHello{ + ProtocolVersion: pbws.ProtocolVersion, + Role: gatewayv2.ClientRole_CLIENT_ROLE_BROWSER, + Token: "ws-token", + }, + }, + }) + hello := receiveTerminalServerFrame(t, conn).GetHello() + if hello == nil || !hello.GetOk() { + t.Fatalf("terminal hello reply = %#v, want ok", hello) + } + return conn, cleanup + } + + attach := func(conn *websocket.Conn) { + sendProtoFrame(t, conn, &gatewayv2.TerminalClientFrame{ + Payload: &gatewayv2.TerminalClientFrame_Frame{ + Frame: &gatewayv1.TerminalStreamFrame{ + Kind: "attach", + SessionId: "sess-1", + StreamId: "stream-1", + }, + }, + }) + } + + // 默认设置:Web 终端关闭 → 权限错误。 + conn, cleanup := dialTerminal() + attach(conn) + frame := receiveTerminalServerFrame(t, conn).GetFrame() + if frame.GetKind() != "error" || frame.GetError() == "" { + t.Fatalf("gated attach reply = %#v, want error frame", frame) + } + cleanup() + + // 开启 Web 终端:attach 通过门控,但 agent 离线 → 离线错误。 + sm.ApplySettingsJSON(`{"remote":{"enableWebTerminal":true}}`) + conn, cleanup = dialTerminal() + defer cleanup() + attach(conn) + frame = receiveTerminalServerFrame(t, conn).GetFrame() + if frame.GetKind() != "error" || frame.GetError() != "desktop agent is offline" { + t.Fatalf("offline attach reply = %#v, want agent offline error", frame) + } +} + +// TestV2AgentHelloRejectsBrowserRole 确认角色错配被拒绝。 +func TestV2AgentHelloRejectsBrowserRole(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + srv := pbws.NewServer(newV2TestConfig(), sm) + mux := http.NewServeMux() + mux.Handle("/ws/v2/agent", srv.AgentHandler()) + + conn, cleanup := dialV2Path(t, mux, "/ws/v2/agent") + defer cleanup() + + sendProtoFrame(t, conn, &gatewayv2.AgentClientFrame{ + Payload: &gatewayv2.AgentClientFrame_Hello{ + Hello: &gatewayv2.ClientHello{ + ProtocolVersion: pbws.ProtocolVersion, + Role: gatewayv2.ClientRole_CLIENT_ROLE_BROWSER, + Token: "ws-token", + }, + }, + }) + hello := receiveAgentServerFrame(t, conn).GetHello() + if hello == nil || hello.GetOk() { + t.Fatalf("agent hello with browser role = %#v, want ok=false", hello) + } +} + +func receiveTerminalServerFrame(t *testing.T, conn *websocket.Conn) *gatewayv2.TerminalServerFrame { + t.Helper() + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set terminal read deadline: %v", err) + } + messageType, data, err := conn.ReadMessage() + if err != nil { + t.Fatalf("receive terminal frame: %v", err) + } + if messageType != websocket.BinaryMessage { + t.Fatalf("terminal frame message type = %d, want binary", messageType) + } + var frame gatewayv2.TerminalServerFrame + if err := proto.Unmarshal(data, &frame); err != nil { + t.Fatalf("unmarshal terminal frame: %v", err) + } + return &frame +} diff --git a/crates/agent-gateway/test/websocket/v2_helpers_test.go b/crates/agent-gateway/test/websocket/v2_helpers_test.go new file mode 100644 index 000000000..f6dd08047 --- /dev/null +++ b/crates/agent-gateway/test/websocket/v2_helpers_test.go @@ -0,0 +1,155 @@ +package websocket_test + +// v2(WebSocket+Protobuf)二进制帧测试 harness,与 v1 JSON harness 并列:起真实 httptest +// 服务器、以子协议拨号、按 proto 帧收发。v1 harness 在 v1 删除前保持不动。 + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" + + "github.com/liveagent/agent-gateway/internal/config" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/protocol/pbws" + "github.com/liveagent/agent-gateway/internal/session" +) + +func newV2TestConfig() *config.Config { + return &config.Config{ + Token: "ws-token", + RequestTimeout: time.Second, + } +} + +// dialV2 起服务并以 v2 子协议拨号。 +func dialV2(t *testing.T, handler http.Handler) (*websocket.Conn, func()) { + t.Helper() + ts := httptest.NewServer(handler) + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + dialer := websocket.Dialer{Subprotocols: []string{pbws.Subprotocol}} + conn, resp, err := dialer.Dial(wsURL, http.Header{ + "Origin": []string{ts.URL}, + }) + if err != nil { + ts.Close() + t.Fatalf("dial v2 websocket: %v", err) + } + if got := resp.Header.Get("Sec-Websocket-Protocol"); got != pbws.Subprotocol { + _ = conn.Close() + ts.Close() + t.Fatalf("subprotocol = %q, want %q", got, pbws.Subprotocol) + } + return conn, func() { + _ = conn.Close() + ts.Close() + } +} + +func sendProtoFrame(t *testing.T, conn *websocket.Conn, frame proto.Message) { + t.Helper() + data, err := proto.Marshal(frame) + if err != nil { + t.Fatalf("marshal v2 frame: %v", err) + } + if err := conn.SetWriteDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set v2 write deadline: %v", err) + } + if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil { + t.Fatalf("send v2 frame: %v", err) + } +} + +// receiveWebFrame 读取一条 WebServerFrame,跳过与断言无关的周期/广播帧(对应 v1 harness +// 过滤集);带关联 id 的 status 是 status_get / chat_prepare 的响应,不过滤。 +func receiveWebFrame(t *testing.T, conn *websocket.Conn) *gatewayv2.WebServerFrame { + t.Helper() + for { + frame := receiveWebFrameRaw(t, conn) + switch frame.GetPayload().(type) { + case *gatewayv2.WebServerFrame_Ping, + *gatewayv2.WebServerFrame_TunnelState, + *gatewayv2.WebServerFrame_ProcessState: + continue + case *gatewayv2.WebServerFrame_Status: + if frame.GetRequestId() == "" { + continue + } + return frame + default: + return frame + } + } +} + +func receiveWebFrameRaw(t *testing.T, conn *websocket.Conn) *gatewayv2.WebServerFrame { + t.Helper() + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set v2 read deadline: %v", err) + } + messageType, data, err := conn.ReadMessage() + if err != nil { + t.Fatalf("receive v2 frame: %v", err) + } + if messageType != websocket.BinaryMessage { + t.Fatalf("v2 frame message type = %d, want binary", messageType) + } + var frame gatewayv2.WebServerFrame + if err := proto.Unmarshal(data, &frame); err != nil { + t.Fatalf("unmarshal v2 frame: %v", err) + } + return &frame +} + +// receiveWebFrameWithID 等待携带指定关联 id 的帧。 +func receiveWebFrameWithID(t *testing.T, conn *websocket.Conn, id string) *gatewayv2.WebServerFrame { + t.Helper() + for attempt := 0; attempt < 8; attempt++ { + frame := receiveWebFrame(t, conn) + if frame.GetRequestId() == id { + return frame + } + } + t.Fatalf("timed out waiting for v2 frame id %q", id) + return nil +} + +// helloV2 完成浏览器链路握手并断言成功。 +func helloV2(t *testing.T, conn *websocket.Conn, token string) { + t.Helper() + sendProtoFrame(t, conn, &gatewayv2.WebClientFrame{ + RequestId: "hello-1", + Payload: &gatewayv2.WebClientFrame_Hello{ + Hello: &gatewayv2.ClientHello{ + ProtocolVersion: pbws.ProtocolVersion, + Role: gatewayv2.ClientRole_CLIENT_ROLE_BROWSER, + Token: token, + ClientName: "webui-test", + }, + }, + }) + frame := receiveWebFrameRaw(t, conn) + hello := frame.GetHello() + if hello == nil || !hello.GetOk() { + t.Fatalf("v2 hello reply = %#v, want ok hello", frame) + } +} + +// newV2BrowserTest 建好 manager + 假 agent 会话 + 已握手的浏览器连接。 +func newV2BrowserTest(t *testing.T) (*session.Manager, *session.AgentSession, *websocket.Conn, func()) { + t.Helper() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + + handler := pbws.NewServer(newV2TestConfig(), sm).BrowserHandler() + conn, cleanup := dialV2(t, handler) + helloV2(t, conn, "ws-token") + return sm, agentSession, conn, cleanup +} diff --git a/crates/agent-gateway/test/websocket/v2_test.go b/crates/agent-gateway/test/websocket/v2_test.go new file mode 100644 index 000000000..39eabf8a7 --- /dev/null +++ b/crates/agent-gateway/test/websocket/v2_test.go @@ -0,0 +1,341 @@ +package websocket_test + +// v2 浏览器链路集成测试:真实 httptest 服务器 + 二进制 proto 帧,覆盖握手鉴权、本地操作、 +// 直通转发(白名单/限额/关联 id 命名空间化)、chat 订阅与事件推送。 + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/protocol/pbws" + "github.com/liveagent/agent-gateway/internal/session" +) + +func TestV2HelloRejectsBadToken(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + handler := pbws.NewServer(newV2TestConfig(), sm).BrowserHandler() + conn, cleanup := dialV2(t, handler) + defer cleanup() + + sendProtoFrame(t, conn, &gatewayv2.WebClientFrame{ + RequestId: "hello-bad", + Payload: &gatewayv2.WebClientFrame_Hello{ + Hello: &gatewayv2.ClientHello{ + ProtocolVersion: pbws.ProtocolVersion, + Token: "wrong-token", + }, + }, + }) + frame := receiveWebFrameRaw(t, conn) + hello := frame.GetHello() + if hello == nil || hello.GetOk() { + t.Fatalf("hello reply = %#v, want ok=false", frame) + } + // 其后连接应被服务端关闭。 + _ = conn.SetReadDeadline(time.Now().Add(time.Second)) + if _, _, err := conn.ReadMessage(); err == nil { + t.Fatal("connection stayed open after rejected hello") + } +} + +func TestV2HelloRejectsWrongVersion(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + handler := pbws.NewServer(newV2TestConfig(), sm).BrowserHandler() + conn, cleanup := dialV2(t, handler) + defer cleanup() + + sendProtoFrame(t, conn, &gatewayv2.WebClientFrame{ + Payload: &gatewayv2.WebClientFrame_Hello{ + Hello: &gatewayv2.ClientHello{ProtocolVersion: 99, Token: "ws-token"}, + }, + }) + frame := receiveWebFrameRaw(t, conn) + if hello := frame.GetHello(); hello == nil || hello.GetOk() { + t.Fatalf("hello reply = %#v, want ok=false for wrong version", frame) + } +} + +func TestV2StatusGet(t *testing.T) { + t.Parallel() + + _, _, conn, cleanup := newV2BrowserTest(t) + defer cleanup() + + sendProtoFrame(t, conn, &gatewayv2.WebClientFrame{ + RequestId: "status-1", + Payload: &gatewayv2.WebClientFrame_StatusGet{StatusGet: &gatewayv2.StatusGetRequest{}}, + }) + frame := receiveWebFrameWithID(t, conn, "status-1") + status := frame.GetStatus() + if status == nil { + t.Fatalf("status.get reply = %#v, want status payload", frame) + } + if !status.GetOnline() || status.GetAgentId() != "desktop-agent" { + t.Fatalf("status = %#v, want online desktop-agent", status) + } +} + +func TestV2AgentRequestPassthroughRoundtrip(t *testing.T) { + t.Parallel() + + sm, agentSession, conn, cleanup := newV2BrowserTest(t) + defer cleanup() + + // page_size 越界应被网关钳制到 v1 相同的上限(200)。 + sendProtoFrame(t, conn, &gatewayv2.WebClientFrame{ + RequestId: "hist-1", + Payload: &gatewayv2.WebClientFrame_AgentRequest{ + AgentRequest: &gatewayv1.GatewayEnvelope{ + RequestId: "hist-1", + Payload: &gatewayv1.GatewayEnvelope_HistoryList{ + HistoryList: &gatewayv1.HistoryListRequest{PageSize: 999}, + }, + }, + }, + }) + + outbound := readOutboundEnvelope(t, agentSession) + if !strings.HasSuffix(outbound.GetRequestId(), ":hist-1") || + outbound.GetRequestId() == "hist-1" { + t.Fatalf("agent request id = %q, want per-connection namespaced hist-1", outbound.GetRequestId()) + } + if got := outbound.GetHistoryList().GetPageSize(); got != 200 { + t.Fatalf("page_size = %d, want clamped to 200", got) + } + if got := outbound.GetHistoryList().GetPage(); got != 1 { + t.Fatalf("page = %d, want defaulted to 1", got) + } + + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: outbound.GetRequestId(), + Payload: &gatewayv1.AgentEnvelope_HistoryListResp{ + HistoryListResp: &gatewayv1.HistoryListResponse{TotalCount: 3}, + }, + }) + + frame := receiveWebFrameWithID(t, conn, "hist-1") + response := frame.GetAgentResponse() + if response == nil { + t.Fatalf("passthrough reply = %#v, want agent_response", frame) + } + // 回程信封的关联 id 已剥离命名空间前缀。 + if response.GetRequestId() != "hist-1" { + t.Fatalf("agent_response request_id = %q, want hist-1", response.GetRequestId()) + } + if response.GetHistoryListResp().GetTotalCount() != 3 { + t.Fatalf("history list resp = %#v, want total_count 3", response) + } +} + +func TestV2GuardRejectsNonWhitelistedArms(t *testing.T) { + t.Parallel() + + _, _, conn, cleanup := newV2BrowserTest(t) + defer cleanup() + + // chat_command 必须走网关编排帧,不允许直通。 + sendProtoFrame(t, conn, &gatewayv2.WebClientFrame{ + RequestId: "bad-1", + Payload: &gatewayv2.WebClientFrame_AgentRequest{ + AgentRequest: &gatewayv1.GatewayEnvelope{ + Payload: &gatewayv1.GatewayEnvelope_ChatCommand{ + ChatCommand: &gatewayv1.ChatCommandRequest{Type: "chat.submit"}, + }, + }, + }, + }) + frame := receiveWebFrameWithID(t, conn, "bad-1") + if frame.GetLocalError() == nil { + t.Fatalf("chat_command passthrough reply = %#v, want local_error", frame) + } + + // 内部推送臂同理。 + sendProtoFrame(t, conn, &gatewayv2.WebClientFrame{ + RequestId: "bad-2", + Payload: &gatewayv2.WebClientFrame_AgentRequest{ + AgentRequest: &gatewayv1.GatewayEnvelope{ + Payload: &gatewayv1.GatewayEnvelope_Ping{ + Ping: &gatewayv1.PingRequest{}, + }, + }, + }, + }) + frame = receiveWebFrameWithID(t, conn, "bad-2") + if frame.GetLocalError() == nil { + t.Fatalf("ping passthrough reply = %#v, want local_error", frame) + } +} + +func TestV2ChatSubscribeAndStreamEvents(t *testing.T) { + t.Parallel() + + sm, _, conn, cleanup := newV2BrowserTest(t) + defer cleanup() + + sendProtoFrame(t, conn, &gatewayv2.WebClientFrame{ + RequestId: "sub-1", + Payload: &gatewayv2.WebClientFrame_ChatSubscribe{ + ChatSubscribe: &gatewayv2.ChatSubscribeRequest{ConversationId: "conv-1"}, + }, + }) + frame := receiveWebFrameWithID(t, conn, "sub-1") + subscribed := frame.GetChatSubscribed() + if subscribed == nil || subscribed.GetConversationId() != "conv-1" { + t.Fatalf("chat_subscribe reply = %#v, want chat_subscribed conv-1", frame) + } + + dispatchStarted(sm, "run-1", "conv-1") + tokenData, _ := json.Marshal(map[string]any{"text": "hello"}) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "run-1", + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: "conv-1", + Data: string(tokenData), + }, + }, + }) + + // 依次应收到 started 与 token 两条流事件。 + sawToken := false + for attempt := 0; attempt < 8 && !sawToken; attempt++ { + frame := receiveWebFrame(t, conn) + event := frame.GetChatEvent() + if event == nil { + continue + } + if event.GetConversationId() != "conv-1" { + t.Fatalf("chat_event conversation = %q, want conv-1", event.GetConversationId()) + } + var payload map[string]any + if err := json.Unmarshal(event.GetPayloadJson(), &payload); err != nil { + t.Fatalf("chat_event payload_json invalid: %v", err) + } + if payload["type"] == "token" { + sawToken = true + } + } + if !sawToken { + t.Fatal("timed out waiting for token chat_event") + } +} + +// TestV2EndToEndBinaryPath 打通首条全二进制路径:假 agent 经 /ws/v2/agent 接入, +// 浏览器经 /ws/v2 直通请求,全程无 JSON、无 gRPC。 +func TestV2EndToEndBinaryPath(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + srv := pbws.NewServer(newV2TestConfig(), sm) + + mux := http.NewServeMux() + mux.Handle("/ws/v2", srv.BrowserHandler()) + mux.Handle("/ws/v2/agent", srv.AgentHandler()) + + // ---- 假 agent 上线 ---- + agentConn, agentCleanup := dialV2Path(t, mux, "/ws/v2/agent") + defer agentCleanup() + sendProtoFrame(t, agentConn, &gatewayv2.AgentClientFrame{ + Payload: &gatewayv2.AgentClientFrame_Hello{ + Hello: &gatewayv2.ClientHello{ + ProtocolVersion: pbws.ProtocolVersion, + Role: gatewayv2.ClientRole_CLIENT_ROLE_AGENT, + Token: "ws-token", + AgentId: "desktop-agent", + AgentVersion: "1.0.0", + }, + }, + }) + agentHello := receiveAgentServerFrame(t, agentConn).GetHello() + if agentHello == nil || !agentHello.GetOk() || agentHello.GetSessionId() == "" { + t.Fatalf("agent hello reply = %#v, want ok with session id", agentHello) + } + + // ---- 浏览器接入并发起直通请求 ---- + browserConn, browserCleanup := dialV2Path(t, mux, "/ws/v2") + defer browserCleanup() + helloV2(t, browserConn, "ws-token") + + sendProtoFrame(t, browserConn, &gatewayv2.WebClientFrame{ + RequestId: "e2e-1", + Payload: &gatewayv2.WebClientFrame_AgentRequest{ + AgentRequest: &gatewayv1.GatewayEnvelope{ + Payload: &gatewayv1.GatewayEnvelope_SettingsGet{ + SettingsGet: &gatewayv1.SettingsGetRequest{}, + }, + }, + }, + }) + + // agent 侧应收到直通信封(跳过心跳 Ping)。 + var inbound *gatewayv1.GatewayEnvelope + for attempt := 0; attempt < 8; attempt++ { + envelope := receiveAgentServerFrame(t, agentConn).GetEnvelope() + if envelope == nil || envelope.GetPing() != nil { + continue + } + inbound = envelope + break + } + if inbound == nil || inbound.GetSettingsGet() == nil { + t.Fatalf("agent inbound = %#v, want settings_get", inbound) + } + + sendProtoFrame(t, agentConn, &gatewayv2.AgentClientFrame{ + Payload: &gatewayv2.AgentClientFrame_Envelope{ + Envelope: &gatewayv1.AgentEnvelope{ + RequestId: inbound.GetRequestId(), + Payload: &gatewayv1.AgentEnvelope_SettingsGetResp{ + SettingsGetResp: &gatewayv1.SettingsGetResponse{SettingsJson: `{"ok":true}`}, + }, + }, + }, + }) + + frame := receiveWebFrameWithID(t, browserConn, "e2e-1") + response := frame.GetAgentResponse() + if response == nil || response.GetSettingsGetResp().GetSettingsJson() != `{"ok":true}` { + t.Fatalf("e2e reply = %#v, want settings_get_resp", frame) + } +} + +// dialV2Path 对多路由 mux 的指定路径拨号。 +func dialV2Path(t *testing.T, handler http.Handler, path string) (*websocket.Conn, func()) { + t.Helper() + return dialV2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = path + handler.ServeHTTP(w, r) + })) +} + +func receiveAgentServerFrame(t *testing.T, conn *websocket.Conn) *gatewayv2.AgentServerFrame { + t.Helper() + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set agent read deadline: %v", err) + } + messageType, data, err := conn.ReadMessage() + if err != nil { + t.Fatalf("receive agent frame: %v", err) + } + if messageType != websocket.BinaryMessage { + t.Fatalf("agent frame message type = %d, want binary", messageType) + } + var frame gatewayv2.AgentServerFrame + if err := proto.Unmarshal(data, &frame); err != nil { + t.Fatalf("unmarshal agent frame: %v", err) + } + return &frame +} From af95c057a60efc76d5182eed617142894b18e56a Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:20:10 +0800 Subject: [PATCH 06/10] feat(webui): switch gateway client to v2 binary protocol - /ws/v2 with liveagent.v2.pb subprotocol; protoc-gen-es generated codecs (web/src/lib/proto/gen) plus adapters restoring v1-shaped app payloads (single bigint boundary, oneof dispatch) - terminal client moves to /ws/v2/terminal proto frames, replacing the custom binary framing; public client API unchanged - v1 wire internals deleted (embedded WebUI is lockstep with the gateway); tests rewritten for binary frames + new adapter unit tests --- .../agent-gateway/test/helpers/gateway-v2.mjs | 68 + .../test/webui/gateway-socket-client.test.mjs | 1650 +++--- crates/agent-gateway/web/biome.json | 3 +- crates/agent-gateway/web/package.json | 2 + crates/agent-gateway/web/pnpm-lock.yaml | 59 + .../web/src/lib/gatewaySocket.ts | 225 +- .../web/src/lib/gatewaySocketV2/adapters.ts | 1680 ++++++ .../src/lib/proto/gen/proto/v1/gateway_pb.ts | 5211 +++++++++++++++++ .../lib/proto/gen/proto/v2/gateway_ws_pb.ts | 1195 ++++ .../terminal/gatewayTerminalStreamClient.ts | 226 +- .../web/test/gateway-v2-adapters.test.mjs | 259 + 11 files changed, 9507 insertions(+), 1071 deletions(-) create mode 100644 crates/agent-gateway/test/helpers/gateway-v2.mjs create mode 100644 crates/agent-gateway/web/src/lib/gatewaySocketV2/adapters.ts create mode 100644 crates/agent-gateway/web/src/lib/proto/gen/proto/v1/gateway_pb.ts create mode 100644 crates/agent-gateway/web/src/lib/proto/gen/proto/v2/gateway_ws_pb.ts create mode 100644 crates/agent-gateway/web/test/gateway-v2-adapters.test.mjs diff --git a/crates/agent-gateway/test/helpers/gateway-v2.mjs b/crates/agent-gateway/test/helpers/gateway-v2.mjs new file mode 100644 index 000000000..d25f3dae7 --- /dev/null +++ b/crates/agent-gateway/test/helpers/gateway-v2.mjs @@ -0,0 +1,68 @@ +// v2 线协议测试编解码器:用与被测代码同一份生成 schema + protobuf 运行时 +// 编解码二进制帧,让 FakeWebSocket 以 v2 服务端的身份说话。 +// 服务端帧用 protojson 形状(proto 字段名 + oneof 臂即普通字段)构造, +// bytes 字段传 base64 字符串。 +export function createGatewayV2Codec(loader) { + const pb = loader.loadModule("@bufbuild/protobuf"); + const v2 = loader.loadModule("src/lib/proto/gen/proto/v2/gateway_ws_pb.ts"); + + const toBytes = (data) => { + if (data instanceof Uint8Array) return data; + if (data instanceof ArrayBuffer) return new Uint8Array(data); + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + throw new Error("expected binary frame data"); + }; + + // 编码为 ArrayBuffer(浏览器 binaryType="arraybuffer" 时 event.data 的形状)。 + const toArrayBuffer = (u8) => u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength); + + function decodeClientFrame(data) { + const frame = pb.fromBinary(v2.WebClientFrameSchema, toBytes(data)); + const json = pb.toJson(v2.WebClientFrameSchema, frame, { useProtoFieldName: true }); + return { + requestId: frame.requestId ?? "", + case: frame.payload?.case, + json, + frame, + }; + } + + // init 为 protojson 形状,如 { request_id: "r1", status: { online: true } }。 + function encodeServerFrame(init) { + const frame = pb.fromJson(v2.WebServerFrameSchema, init); + return toArrayBuffer(pb.toBinary(v2.WebServerFrameSchema, frame)); + } + + function decodeTerminalClientFrame(data) { + const frame = pb.fromBinary(v2.TerminalClientFrameSchema, toBytes(data)); + const json = pb.toJson(v2.TerminalClientFrameSchema, frame, { useProtoFieldName: true }); + return { case: frame.payload?.case, json, frame }; + } + + function encodeTerminalServerFrame(init) { + const frame = pb.fromJson(v2.TerminalServerFrameSchema, init); + return toArrayBuffer(pb.toBinary(v2.TerminalServerFrameSchema, frame)); + } + + // bytes 字段的 protojson 形式:字符串按 UTF-8、二进制原样、其余 JSON 序列化。 + const base64 = (value) => { + if (value instanceof Uint8Array || Array.isArray(value)) { + return Buffer.from(value).toString("base64"); + } + return Buffer.from(typeof value === "string" ? value : JSON.stringify(value)).toString( + "base64", + ); + }; + + return { + pb, + v2, + decodeClientFrame, + encodeServerFrame, + decodeTerminalClientFrame, + encodeTerminalServerFrame, + base64, + }; +} 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 069ea8deb..ff031f4e9 100644 --- a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs +++ b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { createGatewayV2Codec } from "../helpers/gateway-v2.mjs"; import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; +// FakeWebSocket 以 v2 服务端身份说话:收发全部为二进制 protobuf 帧。 class FakeWebSocket { static CONNECTING = 0; static OPEN = 1; @@ -11,18 +13,20 @@ class FakeWebSocket { readyState = FakeWebSocket.CONNECTING; sent = []; + binaryType = "blob"; onopen = null; onmessage = null; onerror = null; onclose = null; - constructor(url) { + constructor(url, protocols) { this.url = url; + this.protocols = protocols; FakeWebSocket.instances.push(this); } send(raw) { - this.sent.push(typeof raw === "string" ? JSON.parse(raw) : raw); + this.sent.push(raw); } open() { @@ -30,11 +34,7 @@ class FakeWebSocket { this.onopen?.(); } - receive(envelope) { - this.onmessage?.({ data: JSON.stringify(envelope) }); - } - - receiveRaw(data) { + receiveBinary(data) { this.onmessage?.({ data }); } @@ -112,81 +112,117 @@ function waitFor(predicate, label) { }); } -async function connectAndAuth(index = 0) { +function loadGatewaySocket() { + const loader = createWebModuleLoader(); + const codec = createGatewayV2Codec(loader); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( + "src/lib/gatewaySocket.ts", + ); + return { loader, codec, getGatewayWebSocketClient, resetGatewayWebSocketClient }; +} + +function frames(codec, socket) { + return socket.sent.map((raw) => codec.decodeClientFrame(raw)); +} + +// 查找第一条命中指定直通臂的 agent_request 帧。 +function findAgentRequest(codec, socket, arm) { + return frames(codec, socket).find( + (frame) => frame.case === "agentRequest" && frame.json.agent_request?.[arm] !== undefined, + ); +} + +function findFrame(codec, socket, frameCase) { + return frames(codec, socket).find((frame) => frame.case === frameCase); +} + +async function connectAndAuth(codec, index = 0) { await waitFor(() => FakeWebSocket.instances.length > index, "websocket construction"); const socket = FakeWebSocket.instances[index]; socket.open(); - await waitFor(() => socket.sent.length >= 1, "auth envelope"); - assert.equal(socket.url, "wss://gateway.example/ws"); - assert.equal(socket.sent[0].type, "auth"); - assert.deepEqual(socket.sent[0].payload, { token: "token" }); - socket.receive({ id: socket.sent[0].id, type: "response", payload: { ok: true } }); + await waitFor(() => socket.sent.length >= 1, "hello frame"); + assert.equal(socket.url, "wss://gateway.example/ws/v2"); + assert.equal(socket.protocols, "liveagent.v2.pb"); + assert.equal(socket.binaryType, "arraybuffer"); + const hello = codec.decodeClientFrame(socket.sent[0]); + assert.equal(hello.case, "hello"); + assert.equal(hello.json.hello.protocol_version, 2); + assert.equal(hello.json.hello.token, "token"); + assert.equal(hello.json.hello.client_name, "webui"); + socket.receiveBinary(codec.encodeServerFrame({ request_id: hello.requestId, hello: { ok: true } })); return socket; } -function encodeTerminalStreamFrame(header, data = new Uint8Array()) { - const headerBytes = new TextEncoder().encode(JSON.stringify(header)); - const payload = new Uint8Array(4 + headerBytes.byteLength + data.byteLength); - payload[0] = 1; - payload[1] = { attach: 1, input: 2, resize: 3, detach: 4, output: 5, snapshot: 6, error: 7 }[ - header.kind - ] ?? 0; - new DataView(payload.buffer).setUint16(2, headerBytes.byteLength, false); - payload.set(headerBytes, 4); - payload.set(data, 4 + headerBytes.byteLength); - return payload; -} - -function decodeTerminalStreamFrame(payload) { - const bytes = payload instanceof Uint8Array ? payload : new Uint8Array(payload); - assert.equal(bytes[0], 1); - const headerLength = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16( - 2, - false, - ); - const headerStart = 4; - const headerEnd = headerStart + headerLength; - return { - header: JSON.parse(new TextDecoder().decode(bytes.subarray(headerStart, headerEnd))), - data: bytes.slice(headerEnd), - }; +// history.list 现在并行发出 chat_activities 帧;用网关状态应答它。 +function answerChatActivities(codec, socket, running = [], answered = new Set()) { + for (const frame of frames(codec, socket)) { + if (frame.case !== "chatActivities" || answered.has(frame.requestId)) continue; + answered.add(frame.requestId); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: frame.requestId, + chat_activities: { running_conversations: running }, + }), + ); + } } -test("GatewayWebSocketClient authenticates once and sends status requests over /ws", async () => { +test("GatewayWebSocketClient authenticates via hello and sends status_get over /ws/v2", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient(" token "); const statusPromise = client.getStatus(); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.length >= 2, "status envelope"); - assert.equal(socket.sent[1].type, "status.get"); - socket.receive({ - id: socket.sent[1].id, - type: "response", - payload: { online: true, agent_id: "desktop-agent" }, - }); + const socket = await connectAndAuth(codec); + await waitFor(() => findFrame(codec, socket, "statusGet"), "status_get frame"); + const statusRequest = findFrame(codec, socket, "statusGet"); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: statusRequest.requestId, + status: { online: true, agent_id: "desktop-agent" }, + }), + ); const status = await statusPromise; - assert.deepEqual(status, { online: true, agent_id: "desktop-agent" }); + assert.equal(status.online, true); + assert.equal(status.agent_id, "desktop-agent"); + resetGatewayWebSocketClient(); +}); + +test("GatewayWebSocketClient surfaces hello rejection as an auth error without reconnect loops", async () => { + installBrowser(); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); + resetGatewayWebSocketClient(); + + const client = getGatewayWebSocketClient("token"); + const statusPromise = assert.rejects(client.getStatus(), /unauthorized/); + await waitFor(() => FakeWebSocket.instances.length === 1, "websocket construction"); + const socket = FakeWebSocket.instances[0]; + socket.open(); + await waitFor(() => socket.sent.length >= 1, "hello frame"); + const hello = codec.decodeClientFrame(socket.sent[0]); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: hello.requestId, + hello: { ok: false, message: "unauthorized" }, + }), + ); + await statusPromise; + socket.close({ code: 4401, reason: "unauthorized" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(FakeWebSocket.instances.length, 1, "bad token must not trigger a reconnect loop"); resetGatewayWebSocketClient(); }); test("GatewayWebSocketClient bounds the complete WebSocket connection attempt", async () => { const timers = createManualTimers(); installBrowser({ ...timers }); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = - loader.loadModule("src/lib/gatewaySocket.ts"); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); - const statusResult = assert.rejects( - client.getStatus(), - /Gateway WebSocket connection timed out/, - ); + const statusResult = assert.rejects(client.getStatus(), /Gateway WebSocket connection timed out/); await waitFor(() => FakeWebSocket.instances.length === 1, "connecting websocket"); const socket = FakeWebSocket.instances[0]; assert.equal(socket.readyState, FakeWebSocket.CONNECTING); @@ -197,145 +233,160 @@ test("GatewayWebSocketClient bounds the complete WebSocket connection attempt", resetGatewayWebSocketClient(); }); -test("GatewayWebSocketClient sends chat.prepare with the caller reason", async () => { +test("GatewayWebSocketClient sends chat_prepare with the caller reason", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = - loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); const preparePromise = client.prepareChatRuntime("composer-focus"); - const socket = await connectAndAuth(); - await waitFor( - () => socket.sent.some((item) => item.type === "chat.prepare"), - "chat.prepare envelope", - ); - const prepareRequest = socket.sent.find((item) => item.type === "chat.prepare"); - assert.deepEqual(prepareRequest.payload, { reason: "composer-focus" }); - socket.receive({ - id: prepareRequest.id, - type: "response", - payload: { online: true, chat_runtime_ready: true, runtime_state: "ready" }, - }); + const socket = await connectAndAuth(codec); + await waitFor(() => findFrame(codec, socket, "chatPrepare"), "chat_prepare frame"); + const prepareRequest = findFrame(codec, socket, "chatPrepare"); + assert.deepEqual(prepareRequest.json.chat_prepare, { reason: "composer-focus" }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: prepareRequest.requestId, + status: { online: true, chat_runtime_ready: true, runtime_state: "ready" }, + }), + ); - assert.deepEqual(await preparePromise, { - online: true, - chat_runtime_ready: true, - runtime_state: "ready", - }); + const prepared = await preparePromise; + assert.equal(prepared.online, true); + assert.equal(prepared.chat_runtime_ready, true); + assert.equal(prepared.runtime_state, "ready"); resetGatewayWebSocketClient(); }); -test("GatewayWebSocketClient falls back to status.get when chat.prepare is unsupported", async () => { +test("GatewayWebSocketClient falls back to status_get when chat_prepare is unsupported", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = - loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); const preparePromise = client.prepareChatRuntime("foreground"); - const socket = await connectAndAuth(); - await waitFor( - () => socket.sent.some((item) => item.type === "chat.prepare"), - "chat.prepare envelope", + const socket = await connectAndAuth(codec); + await waitFor(() => findFrame(codec, socket, "chatPrepare"), "chat_prepare frame"); + const prepareRequest = findFrame(codec, socket, "chatPrepare"); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: prepareRequest.requestId, + local_error: { message: "unsupported request type" }, + }), ); - const prepareRequest = socket.sent.find((item) => item.type === "chat.prepare"); - socket.receive({ id: prepareRequest.id, type: "error", error: "unsupported request type" }); - await waitFor( - () => socket.sent.some((item) => item.type === "status.get"), - "fallback status.get envelope", + await waitFor(() => findFrame(codec, socket, "statusGet"), "fallback status_get frame"); + const statusRequest = findFrame(codec, socket, "statusGet"); + socket.receiveBinary( + codec.encodeServerFrame({ request_id: statusRequest.requestId, status: { online: true } }), ); - const statusRequest = socket.sent.find((item) => item.type === "status.get"); - socket.receive({ id: statusRequest.id, type: "response", payload: { online: true } }); - assert.deepEqual(await preparePromise, { online: true }); + assert.equal((await preparePromise).online, true); resetGatewayWebSocketClient(); }); test("GatewayWebSocketClient clears a timed-out prepare so the next wake can retry", async () => { const timers = createManualTimers(); installBrowser({ ...timers }); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = - loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); const firstResult = assert.rejects(client.prepareChatRuntime("foreground"), /Gateway/); - const firstSocket = await connectAndAuth(0); - await waitFor( - () => firstSocket.sent.some((item) => item.type === "chat.prepare"), - "first chat.prepare envelope", - ); + const firstSocket = await connectAndAuth(codec, 0); + await waitFor(() => findFrame(codec, firstSocket, "chatPrepare"), "first chat_prepare frame"); timers.fire((timer) => timer.ms === 2_500); await firstResult; const secondPrepare = client.prepareChatRuntime("send"); - const secondSocket = await connectAndAuth(1); - await waitFor( - () => secondSocket.sent.some((item) => item.type === "chat.prepare"), - "second chat.prepare envelope", - ); - const secondRequest = secondSocket.sent.find((item) => item.type === "chat.prepare"); - assert.deepEqual(secondRequest.payload, { reason: "send" }); - secondSocket.receive({ - id: secondRequest.id, - type: "response", - payload: { online: true, chat_runtime_ready: true }, - }); + const secondSocket = await connectAndAuth(codec, 1); + await waitFor(() => findFrame(codec, secondSocket, "chatPrepare"), "second chat_prepare frame"); + const secondRequest = findFrame(codec, secondSocket, "chatPrepare"); + assert.deepEqual(secondRequest.json.chat_prepare, { reason: "send" }); + secondSocket.receiveBinary( + codec.encodeServerFrame({ + request_id: secondRequest.requestId, + status: { online: true, chat_runtime_ready: true }, + }), + ); assert.equal((await secondPrepare).chat_runtime_ready, true); resetGatewayWebSocketClient(); }); -test("BrowserGatewayTerminalStreamClient connects to /ws/terminal and attaches with binary frames", async () => { +const terminalTestSession = { + id: "terminal-1", + projectPathKey: "/workspace/project", + cwd: "/workspace/project", + shell: "zsh", + title: "Terminal 1", + kind: "local", + cols: 80, + rows: 24, + createdAt: 1, + updatedAt: 2, + running: true, +}; + +const terminalProtoSession = { + id: "terminal-1", + project_path_key: "/workspace/project", + cwd: "/workspace/project", + shell: "zsh", + title: "Terminal 1", + kind: "local", + cols: 80, + rows: 24, + created_at: 1, + updated_at: 2, + running: true, +}; + +async function connectTerminalStream(codec, index = 0) { + await waitFor(() => FakeWebSocket.instances.length > index, "terminal stream socket"); + const socket = FakeWebSocket.instances[index]; + socket.open(); + await waitFor(() => socket.sent.length >= 1, "terminal stream hello"); + assert.equal(socket.url, "wss://gateway.example/ws/v2/terminal"); + assert.equal(socket.protocols, "liveagent.v2.pb"); + const hello = codec.decodeTerminalClientFrame(socket.sent[0]); + assert.equal(hello.case, "hello"); + assert.equal(hello.json.hello.token, "token"); + assert.equal(hello.json.hello.role, "CLIENT_ROLE_BROWSER"); + socket.receiveBinary(codec.encodeTerminalServerFrame({ hello: { ok: true } })); + return socket; +} + +test("BrowserGatewayTerminalStreamClient connects to /ws/v2/terminal and attaches with protobuf frames", async () => { installBrowser(); const loader = createWebModuleLoader(); + const codec = createGatewayV2Codec(loader); const { BrowserGatewayTerminalStreamClient } = loader.loadModule( "src/lib/terminal/gatewayTerminalStreamClient.ts", ); - const session = { - id: "terminal-1", - projectPathKey: "/workspace/project", - cwd: "/workspace/project", - shell: "zsh", - title: "Terminal 1", - kind: "local", - cols: 80, - rows: 24, - createdAt: 1, - updatedAt: 2, - running: true, - }; const client = new BrowserGatewayTerminalStreamClient("token"); - const attachPromise = client.attach(session, { maxBytes: 8192 }); - const socket = FakeWebSocket.instances[0]; - socket.open(); - await waitFor(() => socket.sent.length >= 1, "terminal stream auth"); - assert.equal(socket.url, "wss://gateway.example/ws/terminal"); - assert.deepEqual(socket.sent[0], { type: "auth", token: "token" }); - - socket.receive({ type: "ready" }); + const attachPromise = client.attach(terminalTestSession, { maxBytes: 8192 }); + const socket = await connectTerminalStream(codec); await waitFor(() => socket.sent.length >= 2, "terminal stream attach frame"); - const attachFrame = decodeTerminalStreamFrame(socket.sent[1]); - assert.equal(attachFrame.header.kind, "attach"); - assert.equal(attachFrame.header.sessionId, "terminal-1"); - assert.equal(attachFrame.header.projectPathKey, "/workspace/project"); - assert.equal(attachFrame.header.maxBytes, 8192); - - socket.receiveRaw( - encodeTerminalStreamFrame( - { + const attach = codec.decodeTerminalClientFrame(socket.sent[1]); + assert.equal(attach.case, "frame"); + assert.equal(attach.json.frame.kind, "attach"); + assert.equal(attach.json.frame.session_id, "terminal-1"); + assert.equal(attach.json.frame.project_path_key, "/workspace/project"); + assert.equal(attach.json.frame.max_bytes, 8192); + + socket.receiveBinary( + codec.encodeTerminalServerFrame({ + frame: { kind: "snapshot", - streamId: attachFrame.header.streamId, - session, - startOffset: 10, - endOffset: 13, + stream_id: attach.json.frame.stream_id, + session_id: "terminal-1", + project_path_key: "/workspace/project", + session: terminalProtoSession, + start_offset: 10, + end_offset: 13, + data: codec.base64(new Uint8Array([112, 119, 100])), }, - new Uint8Array([112, 119, 100]), - ).buffer, + }), ); const handle = await attachPromise; assert.equal(handle.snapshot.session.id, "terminal-1"); @@ -348,58 +399,45 @@ test("BrowserGatewayTerminalStreamClient connects to /ws/terminal and attaches w test("BrowserGatewayTerminalStreamClient retries attach while desktop stream is offline", async () => { installBrowser(); const loader = createWebModuleLoader(); + const codec = createGatewayV2Codec(loader); const { BrowserGatewayTerminalStreamClient } = loader.loadModule( "src/lib/terminal/gatewayTerminalStreamClient.ts", ); - const session = { - id: "terminal-1", - projectPathKey: "/workspace/project", - cwd: "/workspace/project", - shell: "zsh", - title: "Terminal 1", - kind: "local", - cols: 80, - rows: 24, - createdAt: 1, - updatedAt: 2, - running: true, - }; const client = new BrowserGatewayTerminalStreamClient("token"); - const attachPromise = client.attach(session); - const socket = FakeWebSocket.instances[0]; - socket.open(); - await waitFor(() => socket.sent.length >= 1, "terminal stream auth"); - socket.receive({ type: "ready" }); + const attachPromise = client.attach(terminalTestSession); + const socket = await connectTerminalStream(codec); await waitFor(() => socket.sent.length >= 2, "terminal stream attach frame"); - const firstAttach = decodeTerminalStreamFrame(socket.sent[1]); - - socket.receiveRaw( - encodeTerminalStreamFrame({ - kind: "error", - streamId: firstAttach.header.streamId, - sessionId: "terminal-1", - error: "desktop agent is offline", - }).buffer, + const firstAttach = codec.decodeTerminalClientFrame(socket.sent[1]); + + socket.receiveBinary( + codec.encodeTerminalServerFrame({ + frame: { + kind: "error", + stream_id: firstAttach.json.frame.stream_id, + session_id: "terminal-1", + error: "desktop agent is offline", + }, + }), ); await waitFor(() => socket.sent.length >= 3, "retry terminal stream attach frame"); - const retryAttach = decodeTerminalStreamFrame(socket.sent[2]); - assert.equal(retryAttach.header.kind, "attach"); - assert.equal(retryAttach.header.streamId, firstAttach.header.streamId); - assert.equal(retryAttach.header.sessionId, "terminal-1"); - - socket.receiveRaw( - encodeTerminalStreamFrame( - { + const retryAttach = codec.decodeTerminalClientFrame(socket.sent[2]); + assert.equal(retryAttach.json.frame.kind, "attach"); + assert.equal(retryAttach.json.frame.stream_id, firstAttach.json.frame.stream_id); + assert.equal(retryAttach.json.frame.session_id, "terminal-1"); + + socket.receiveBinary( + codec.encodeTerminalServerFrame({ + frame: { kind: "snapshot", - streamId: retryAttach.header.streamId, - session, - startOffset: 0, - endOffset: 2, + stream_id: retryAttach.json.frame.stream_id, + session_id: "terminal-1", + session: terminalProtoSession, + end_offset: 2, + data: codec.base64(new Uint8Array([111, 107])), }, - new Uint8Array([111, 107]), - ).buffer, + }), ); const handle = await attachPromise; assert.deepEqual([...handle.snapshot.bytes], [111, 107]); @@ -407,79 +445,50 @@ test("BrowserGatewayTerminalStreamClient retries attach while desktop stream is client.dispose(); }); -test("BrowserGatewayTerminalStreamClient falls back to /ws terminal query when /ws/terminal fails", async () => { +test("BrowserGatewayTerminalStreamClient rejects the attach when the hello is refused", async () => { installBrowser(); const loader = createWebModuleLoader(); + const codec = createGatewayV2Codec(loader); const { BrowserGatewayTerminalStreamClient } = loader.loadModule( "src/lib/terminal/gatewayTerminalStreamClient.ts", ); - const session = { - id: "terminal-1", - projectPathKey: "/workspace/project", - cwd: "/workspace/project", - shell: "zsh", - title: "Terminal 1", - kind: "local", - cols: 80, - rows: 24, - createdAt: 1, - updatedAt: 2, - running: true, - }; const client = new BrowserGatewayTerminalStreamClient("token"); - const attachPromise = client.attach(session); - - await waitFor(() => FakeWebSocket.instances.length >= 1, "primary terminal stream socket"); - const primarySocket = FakeWebSocket.instances[0]; - assert.equal(primarySocket.url, "wss://gateway.example/ws/terminal"); - primarySocket.onerror?.({ type: "error" }); - - await waitFor(() => FakeWebSocket.instances.length >= 2, "fallback terminal stream socket"); - const fallbackSocket = FakeWebSocket.instances[1]; - assert.equal(fallbackSocket.url, "wss://gateway.example/ws?terminal=1"); - fallbackSocket.open(); - await waitFor(() => fallbackSocket.sent.length >= 1, "fallback terminal stream auth"); - assert.deepEqual(fallbackSocket.sent[0], { type: "auth", token: "token" }); - fallbackSocket.receive({ type: "ready" }); - await waitFor(() => fallbackSocket.sent.length >= 2, "fallback terminal stream attach frame"); - const attachFrame = decodeTerminalStreamFrame(fallbackSocket.sent[1]); - fallbackSocket.receiveRaw( - encodeTerminalStreamFrame({ - kind: "snapshot", - streamId: attachFrame.header.streamId, - session, - startOffset: 0, - endOffset: 0, - }).buffer, + const attachPromise = assert.rejects(client.attach(terminalTestSession), /unauthorized/); + await waitFor(() => FakeWebSocket.instances.length >= 1, "terminal stream socket"); + const socket = FakeWebSocket.instances[0]; + socket.open(); + await waitFor(() => socket.sent.length >= 1, "terminal stream hello"); + socket.receiveBinary( + codec.encodeTerminalServerFrame({ hello: { ok: false, message: "unauthorized" } }), ); - - const handle = await attachPromise; - assert.equal(handle.snapshot.session.id, "terminal-1"); - handle.dispose(); + await attachPromise; client.dispose(); }); test("GatewayWebSocketClient sends git requests with workdir and args", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient(" token "); const gitPromise = client.gitRequest("diff", "/workspace/project", { mode: "branch" }); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.some((message) => message.type === "git.diff"), "git.diff envelope"); - const request = socket.sent.find((message) => message.type === "git.diff"); - assert.deepEqual(request.payload, { - workdir: "/workspace/project", - args: { mode: "branch" }, - }); - socket.receive({ - id: request.id, - type: "response", - payload: { patch: "diff --git a/file b/file" }, - }); + const socket = await connectAndAuth(codec); + await waitFor(() => findAgentRequest(codec, socket, "git_request"), "git_request frame"); + const request = findAgentRequest(codec, socket, "git_request"); + assert.equal(request.json.agent_request.git_request.action, "diff"); + assert.equal(request.json.agent_request.git_request.workdir, "/workspace/project"); + assert.deepEqual(JSON.parse(request.json.agent_request.git_request.args_json), { + mode: "branch", + }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: request.requestId, + agent_response: { + git_response: { result_json: JSON.stringify({ patch: "diff --git a/file b/file" }) }, + }, + }), + ); assert.deepEqual(await gitPromise, { patch: "diff --git a/file b/file" }); resetGatewayWebSocketClient(); @@ -487,14 +496,14 @@ test("GatewayWebSocketClient sends git requests with workdir and args", async () test("GatewayWebSocketClient does not recover mutating git requests", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient(" token "); const stagePromise = client.gitRequest("stage", "/workspace/project", { path: "src/main.rs" }); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.some((message) => message.type === "git.stage"), "git.stage envelope"); + const socket = await connectAndAuth(codec); + await waitFor(() => findAgentRequest(codec, socket, "git_request"), "git_request frame"); + assert.equal(findAgentRequest(codec, socket, "git_request").json.agent_request.git_request.action, "stage"); socket.close({ code: 1006, wasClean: false }); await assert.rejects(stagePromise, /Gateway WebSocket disconnected/); @@ -504,28 +513,33 @@ test("GatewayWebSocketClient does not recover mutating git requests", async () = test("GatewayWebSocketClient sends mention query payloads", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); const mentionPromise = client.listMentionFiles("/workspace", 200, "src"); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.length >= 2, "mentions envelope"); - assert.equal(socket.sent[1].type, "mentions.list"); - assert.deepEqual(socket.sent[1].payload, { + const socket = await connectAndAuth(codec); + await waitFor(() => findAgentRequest(codec, socket, "file_mention_list"), "mentions frame"); + const request = findAgentRequest(codec, socket, "file_mention_list"); + assert.deepEqual(request.json.agent_request.file_mention_list, { workdir: "/workspace", max_results: 200, query: "src", }); - socket.receive({ - id: socket.sent[1].id, - type: "response", - payload: { entries: [{ path: "src/main.ts", kind: "file" }], truncated: false }, - }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: request.requestId, + agent_response: { + file_mention_list_resp: { + entries: [{ path: "src/main.ts", kind: "file" }], + truncated: false, + }, + }, + }), + ); assert.deepEqual(await mentionPromise, { - entries: [{ path: "src/main.ts", kind: "file" }], + entries: [{ path: "src/main.ts", kind: "file", hidden: false }], truncated: false, }); resetGatewayWebSocketClient(); @@ -533,8 +547,7 @@ test("GatewayWebSocketClient sends mention query payloads", async () => { test("GatewayWebSocketClient sends memory manage payloads", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -542,18 +555,24 @@ test("GatewayWebSocketClient sends memory manage payloads", async () => { command: "memory_search", args: { query: "Kevin", limit: 3 }, }); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.length >= 2, "memory envelope"); - assert.equal(socket.sent[1].type, "memory.manage"); - assert.deepEqual(socket.sent[1].payload, { - command: "memory_search", - args: { query: "Kevin", limit: 3 }, - }); - socket.receive({ - id: socket.sent[1].id, - type: "response", - payload: { matches: [], usedFallback: false }, - }); + const socket = await connectAndAuth(codec); + await waitFor(() => findAgentRequest(codec, socket, "memory_manage"), "memory frame"); + const request = findAgentRequest(codec, socket, "memory_manage"); + assert.equal(request.json.agent_request.memory_manage.command, "memory_search"); + assert.deepEqual(JSON.parse(request.json.agent_request.memory_manage.args_json), { + query: "Kevin", + limit: 3, + }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: request.requestId, + agent_response: { + memory_manage_resp: { + result_json: JSON.stringify({ matches: [], usedFallback: false }), + }, + }, + }), + ); assert.deepEqual(await memoryPromise, { matches: [], usedFallback: false }); resetGatewayWebSocketClient(); @@ -561,8 +580,7 @@ test("GatewayWebSocketClient sends memory manage payloads", async () => { test("GatewayWebSocketClient retries recoverable memory manage commands after a clean disconnect", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -577,40 +595,27 @@ test("GatewayWebSocketClient retries recoverable memory manage commands after a }, }); - const firstSocket = await connectAndAuth(0); + const firstSocket = await connectAndAuth(codec, 0); await waitFor( - () => firstSocket.sent.some((item) => item.type === "memory.manage"), - "initial memory update envelope", + () => findAgentRequest(codec, firstSocket, "memory_manage"), + "initial memory update frame", ); - const firstRequest = firstSocket.sent.find((item) => item.type === "memory.manage"); - assert.deepEqual(firstRequest.payload, { - command: "memory_organize_run_update", - args: { - runId: "run-1", - safeApplied: 2, - trimmedProtocol: { - manualApplyState: { status: "applied" }, - }, - }, - }); + const firstRequest = findAgentRequest(codec, firstSocket, "memory_manage"); + assert.equal(firstRequest.json.agent_request.memory_manage.command, "memory_organize_run_update"); firstSocket.close({ code: 1000, wasClean: true }); await waitFor(() => FakeWebSocket.instances.length === 2, "memory update recovery websocket"); - const reconnectSocket = FakeWebSocket.instances[1]; - reconnectSocket.open(); - await waitFor(() => reconnectSocket.sent.length >= 1, "memory update recovery auth envelope"); - reconnectSocket.receive({ - id: reconnectSocket.sent[0].id, - type: "response", - payload: { ok: true }, - }); + const reconnectSocket = await connectAndAuth(codec, 1); await waitFor( - () => reconnectSocket.sent.some((item) => item.type === "memory.manage"), - "retried memory update envelope", + () => findAgentRequest(codec, reconnectSocket, "memory_manage"), + "retried memory update frame", ); - const retriedRequest = reconnectSocket.sent.find((item) => item.type === "memory.manage"); - assert.deepEqual(retriedRequest.payload, firstRequest.payload); + const retriedRequest = findAgentRequest(codec, reconnectSocket, "memory_manage"); + assert.deepEqual( + retriedRequest.json.agent_request.memory_manage, + firstRequest.json.agent_request.memory_manage, + ); const payload = { runId: "run-1", status: "succeeded", @@ -618,11 +623,12 @@ test("GatewayWebSocketClient retries recoverable memory manage commands after a manualApplyState: { status: "applied" }, }, }; - reconnectSocket.receive({ - id: retriedRequest.id, - type: "response", - payload, - }); + reconnectSocket.receiveBinary( + codec.encodeServerFrame({ + request_id: retriedRequest.requestId, + agent_response: { memory_manage_resp: { result_json: JSON.stringify(payload) } }, + }), + ); assert.deepEqual(await updatePromise, payload); resetGatewayWebSocketClient(); @@ -630,8 +636,7 @@ test("GatewayWebSocketClient retries recoverable memory manage commands after a test("GatewayWebSocketClient does not replay memory apply batch after a disconnect", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -649,42 +654,42 @@ test("GatewayWebSocketClient does not replay memory apply batch after a disconne }, }); - const socket = await connectAndAuth(0); - await waitFor( - () => socket.sent.some((item) => item.type === "memory.manage"), - "memory apply envelope", - ); + const socket = await connectAndAuth(codec, 0); + await waitFor(() => findAgentRequest(codec, socket, "memory_manage"), "memory apply frame"); socket.close({ code: 1000, wasClean: true }); - await assert.rejects( - applyPromise, - /Gateway WebSocket disconnected \(code=1000 clean=true\)/, - ); + await assert.rejects(applyPromise, /Gateway WebSocket disconnected \(code=1000 clean=true\)/); assert.equal(FakeWebSocket.instances.length, 1); resetGatewayWebSocketClient(); }); test("GatewayWebSocketClient sends skill manage payloads", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); - const skillPromise = client.manageSkill({ - action: "list", - }); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.length >= 2, "skill manage envelope"); - assert.equal(socket.sent[1].type, "skills.manage"); - assert.deepEqual(socket.sent[1].payload, { + const skillPromise = client.manageSkill({ action: "list" }); + const socket = await connectAndAuth(codec); + await waitFor(() => findAgentRequest(codec, socket, "skill_manage"), "skill manage frame"); + const request = findAgentRequest(codec, socket, "skill_manage"); + assert.deepEqual(JSON.parse(request.json.agent_request.skill_manage.payload_json), { action: "list", }); - socket.receive({ - id: socket.sent[1].id, - type: "response", - payload: { action: "list", rootDir: "/Users/me/.liveagent/skills", skills: [] }, - }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: request.requestId, + agent_response: { + skill_manage_resp: { + result_json: JSON.stringify({ + action: "list", + rootDir: "/Users/me/.liveagent/skills", + skills: [], + }), + }, + }, + }), + ); assert.deepEqual(await skillPromise, { action: "list", @@ -694,34 +699,33 @@ test("GatewayWebSocketClient sends skill manage payloads", async () => { resetGatewayWebSocketClient(); }); -test("GatewayWebSocketClient sends history list requests", async () => { +test("GatewayWebSocketClient sends history list requests and merges running conversations", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); const listPromise = client.listHistory(2, 50); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.length >= 2, "history list envelope"); - assert.equal(socket.sent[1].type, "history.list"); - assert.deepEqual(socket.sent[1].payload, { page: 2, page_size: 50 }); - socket.receive({ - id: socket.sent[1].id, - type: "response", - payload: { - conversations: [], - total_count: 0, - running_conversations: [ - { - conversation_id: "conversation-running", - run_id: "run-running", - cwd: "/tmp/project-a", - updated_at: 123, - }, - ], + const socket = await connectAndAuth(codec); + await waitFor(() => findAgentRequest(codec, socket, "history_list"), "history list frame"); + const listRequest = findAgentRequest(codec, socket, "history_list"); + assert.deepEqual(listRequest.json.agent_request.history_list, { page: 2, page_size: 50 }); + await waitFor(() => findFrame(codec, socket, "chatActivities"), "chat_activities frame"); + answerChatActivities(codec, socket, [ + { + conversation_id: "conversation-running", + run_id: "run-running", + state: "running", + workdir: "/tmp/project-a", + updated_at_ms: 123, }, - }); + ]); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: listRequest.requestId, + agent_response: { history_list_resp: { conversations: [], total_count: 0 } }, + }), + ); assert.deepEqual(await listPromise, { conversations: [], total_count: 0, @@ -729,6 +733,7 @@ test("GatewayWebSocketClient sends history list requests", async () => { { conversation_id: "conversation-running", run_id: "run-running", + state: "running", cwd: "/tmp/project-a", updated_at: 123, }, @@ -736,14 +741,23 @@ test("GatewayWebSocketClient sends history list requests", async () => { }); const sharedListPromise = client.listSharedHistory(1, 25); - await waitFor(() => socket.sent.length >= 3, "shared history list envelope"); - assert.equal(socket.sent[2].type, "history.shared_list"); - assert.deepEqual(socket.sent[2].payload, { page: 1, page_size: 25 }); - socket.receive({ - id: socket.sent[2].id, - type: "response", - payload: { conversations: [], total_count: 0 }, + await waitFor(() => findAgentRequest(codec, socket, "memory_manage"), "shared history frame"); + const sharedRequest = findAgentRequest(codec, socket, "memory_manage"); + assert.equal(sharedRequest.json.agent_request.memory_manage.command, "history_shared_list"); + assert.deepEqual(JSON.parse(sharedRequest.json.agent_request.memory_manage.args_json), { + page: 1, + page_size: 25, }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: sharedRequest.requestId, + agent_response: { + memory_manage_resp: { + result_json: JSON.stringify({ conversations: [], total_count: 0 }), + }, + }, + }), + ); assert.deepEqual(await sharedListPromise, { conversations: [], total_count: 0 }); resetGatewayWebSocketClient(); @@ -751,25 +765,27 @@ test("GatewayWebSocketClient sends history list requests", async () => { test("GatewayWebSocketClient sends project-aware history and fs requests", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); + const answeredActivities = new Set(); const client = getGatewayWebSocketClient("token"); const filteredListPromise = client.listHistory(3, 25, { cwd: "/tmp/project-a" }); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.length >= 2, "filtered history list envelope"); - assert.equal(socket.sent[1].type, "history.list"); - assert.deepEqual(socket.sent[1].payload, { + const socket = await connectAndAuth(codec); + await waitFor(() => findAgentRequest(codec, socket, "history_list"), "filtered history frame"); + const filteredRequest = findAgentRequest(codec, socket, "history_list"); + assert.deepEqual(filteredRequest.json.agent_request.history_list, { page: 3, page_size: 25, cwd: "/tmp/project-a", }); - socket.receive({ - id: socket.sent[1].id, - type: "response", - payload: { conversations: [], total_count: 0, running_conversations: [] }, - }); + answerChatActivities(codec, socket, [], answeredActivities); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: filteredRequest.requestId, + agent_response: { history_list_resp: { conversations: [], total_count: 0 } }, + }), + ); assert.deepEqual(await filteredListPromise, { conversations: [], total_count: 0, @@ -777,18 +793,28 @@ test("GatewayWebSocketClient sends project-aware history and fs requests", async }); const chatModeListPromise = client.listHistory(1, 80, { cwdEmpty: true }); - await waitFor(() => socket.sent.length >= 3, "cwd empty history list envelope"); - assert.equal(socket.sent[2].type, "history.list"); - assert.deepEqual(socket.sent[2].payload, { + await waitFor( + () => + frames(codec, socket).filter( + (frame) => frame.case === "agentRequest" && frame.json.agent_request?.history_list, + ).length >= 2, + "cwd empty history frame", + ); + const chatModeRequest = frames(codec, socket) + .filter((frame) => frame.case === "agentRequest" && frame.json.agent_request?.history_list) + .at(-1); + assert.deepEqual(chatModeRequest.json.agent_request.history_list, { page: 1, page_size: 80, cwd_empty: true, }); - socket.receive({ - id: socket.sent[2].id, - type: "response", - payload: { conversations: [], total_count: 0, running_conversations: [] }, - }); + answerChatActivities(codec, socket, [], answeredActivities); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: chatModeRequest.requestId, + agent_response: { history_list_resp: { conversations: [], total_count: 0 } }, + }), + ); assert.deepEqual(await chatModeListPromise, { conversations: [], total_count: 0, @@ -796,33 +822,40 @@ test("GatewayWebSocketClient sends project-aware history and fs requests", async }); const workdirsPromise = client.listHistoryWorkdirs(); - await waitFor(() => socket.sent.length >= 4, "history workdirs envelope"); - assert.equal(socket.sent[3].type, "history.workdirs"); - assert.deepEqual(socket.sent[3].payload, {}); - socket.receive({ - id: socket.sent[3].id, - type: "response", - payload: { - workdirs: [ - { path: "/tmp/project-a", conversation_count: 2, updated_at: 1700000000300 }, - ], - }, - }); + await waitFor(() => findAgentRequest(codec, socket, "history_workdirs"), "history workdirs frame"); + const workdirsRequest = findAgentRequest(codec, socket, "history_workdirs"); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: workdirsRequest.requestId, + agent_response: { + history_workdirs_resp: { + workdirs: [ + { path: "/tmp/project-a", conversation_count: 2, updated_at: 1700000000300 }, + ], + }, + }, + }), + ); assert.deepEqual(await workdirsPromise, { - workdirs: [ - { path: "/tmp/project-a", conversationCount: 2, updatedAt: 1700000000300 }, - ], + workdirs: [{ path: "/tmp/project-a", conversationCount: 2, updatedAt: 1700000000300 }], }); const createPromise = client.createProjectFolder("/tmp", "Project A"); - await waitFor(() => socket.sent.length >= 5, "create project folder envelope"); - assert.equal(socket.sent[4].type, "fs.create_project_folder"); - assert.deepEqual(socket.sent[4].payload, { parent: "/tmp", name: "Project A" }); - socket.receive({ - id: socket.sent[4].id, - type: "response", - payload: { path: "/tmp/Project A" }, - }); + await waitFor( + () => findAgentRequest(codec, socket, "fs_create_project_folder"), + "create project folder frame", + ); + const createRequest = findAgentRequest(codec, socket, "fs_create_project_folder"); + assert.deepEqual(createRequest.json.agent_request.fs_create_project_folder, { + parent: "/tmp", + name: "Project A", + }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: createRequest.requestId, + agent_response: { fs_create_project_folder_resp: { path: "/tmp/Project A" } }, + }), + ); assert.deepEqual(await createPromise, { path: "/tmp/Project A" }); resetGatewayWebSocketClient(); @@ -830,21 +863,22 @@ test("GatewayWebSocketClient sends project-aware history and fs requests", async test("GatewayWebSocketClient defaults invalid history pagination", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); const listPromise = client.listHistory(0, 0); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.length >= 2, "history list envelope"); - assert.equal(socket.sent[1].type, "history.list"); - assert.deepEqual(socket.sent[1].payload, { page: 1, page_size: 80 }); - socket.receive({ - id: socket.sent[1].id, - type: "response", - payload: { conversations: [], total_count: 0, running_conversations: [] }, - }); + const socket = await connectAndAuth(codec); + await waitFor(() => findAgentRequest(codec, socket, "history_list"), "history list frame"); + const listRequest = findAgentRequest(codec, socket, "history_list"); + assert.deepEqual(listRequest.json.agent_request.history_list, { page: 1, page_size: 80 }); + answerChatActivities(codec, socket); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: listRequest.requestId, + agent_response: { history_list_resp: { conversations: [], total_count: 0 } }, + }), + ); assert.deepEqual(await listPromise, { conversations: [], total_count: 0, @@ -852,14 +886,22 @@ test("GatewayWebSocketClient defaults invalid history pagination", async () => { }); const sharedListPromise = client.listSharedHistory(Number.NaN, 500); - await waitFor(() => socket.sent.length >= 3, "shared history list envelope"); - assert.equal(socket.sent[2].type, "history.shared_list"); - assert.deepEqual(socket.sent[2].payload, { page: 1, page_size: 200 }); - socket.receive({ - id: socket.sent[2].id, - type: "response", - payload: { conversations: [], total_count: 0 }, - }); + await waitFor(() => findAgentRequest(codec, socket, "memory_manage"), "shared list frame"); + const sharedRequest = findAgentRequest(codec, socket, "memory_manage"); + assert.deepEqual(JSON.parse(sharedRequest.json.agent_request.memory_manage.args_json), { + page: 1, + page_size: 200, + }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: sharedRequest.requestId, + agent_response: { + memory_manage_resp: { + result_json: JSON.stringify({ conversations: [], total_count: 0 }), + }, + }, + }), + ); assert.deepEqual(await sharedListPromise, { conversations: [], total_count: 0 }); resetGatewayWebSocketClient(); @@ -867,59 +909,63 @@ test("GatewayWebSocketClient defaults invalid history pagination", async () => { test("GatewayWebSocketClient sends history share requests", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); const getPromise = client.getHistoryShare("conversation-1"); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.length >= 2, "history share get envelope"); - assert.equal(socket.sent[1].type, "history.share.get"); - assert.deepEqual(socket.sent[1].payload, { + const socket = await connectAndAuth(codec); + await waitFor(() => findAgentRequest(codec, socket, "history_share_get"), "share get frame"); + const getRequest = findAgentRequest(codec, socket, "history_share_get"); + assert.deepEqual(getRequest.json.agent_request.history_share_get, { conversation_id: "conversation-1", }); - socket.receive({ - id: socket.sent[1].id, - type: "response", - payload: { - conversation_id: "conversation-1", - enabled: false, - token: "", - created_at: 0, - updated_at: 0, - }, - }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: getRequest.requestId, + agent_response: { + history_share_get_resp: { + share: { conversation_id: "conversation-1", enabled: false }, + }, + }, + }), + ); assert.deepEqual(await getPromise, { conversation_id: "conversation-1", enabled: false, token: "", created_at: 0, updated_at: 0, + redact_tool_content: false, }); const setPromise = client.setHistoryShare("conversation-1", true, { redactToolContent: true, }); - await waitFor(() => socket.sent.length >= 3, "history share set envelope"); - assert.equal(socket.sent[2].type, "history.share.set"); - assert.deepEqual(socket.sent[2].payload, { + await waitFor(() => findAgentRequest(codec, socket, "history_share_set"), "share set frame"); + const setRequest = findAgentRequest(codec, socket, "history_share_set"); + assert.deepEqual(setRequest.json.agent_request.history_share_set, { conversation_id: "conversation-1", enabled: true, redact_tool_content: true, }); - socket.receive({ - id: socket.sent[2].id, - type: "response", - payload: { - conversation_id: "conversation-1", - enabled: true, - token: "share-token", - created_at: 10, - updated_at: 20, - redact_tool_content: true, - }, - }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: setRequest.requestId, + agent_response: { + history_share_set_resp: { + share: { + conversation_id: "conversation-1", + enabled: true, + token: "share-token", + created_at: 10, + updated_at: 20, + redact_tool_content: true, + }, + }, + }, + }), + ); assert.deepEqual(await setPromise, { conversation_id: "conversation-1", enabled: true, @@ -932,10 +978,9 @@ test("GatewayWebSocketClient sends history share requests", async () => { resetGatewayWebSocketClient(); }); -test("GatewayWebSocketClient sends history branch requests with a snake_case base message ref", async () => { +test("GatewayWebSocketClient sends history branch requests with the base message ref", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -947,13 +992,10 @@ test("GatewayWebSocketClient sends history branch requests with a snake_case bas role: "user", contentHash: "hash-abc", }); - const socket = await connectAndAuth(); - await waitFor( - () => socket.sent.some((item) => item.type === "history.branch"), - "history branch envelope", - ); - const request = socket.sent.find((item) => item.type === "history.branch"); - assert.deepEqual(request.payload, { + const socket = await connectAndAuth(codec); + await waitFor(() => findAgentRequest(codec, socket, "history_branch"), "history branch frame"); + const request = findAgentRequest(codec, socket, "history_branch"); + assert.deepEqual(request.json.agent_request.history_branch, { conversation_id: "conversation-1", base_message_ref: { segment_index: 2, @@ -964,46 +1006,50 @@ test("GatewayWebSocketClient sends history branch requests with a snake_case bas content_hash: "hash-abc", }, }); - socket.receive({ - id: request.id, - type: "response", - payload: { - id: "conversation-branch", - title: "新分支", - message_count: 6, - created_at: 1700000000100, - updated_at: 1700000000200, - }, - }); - assert.deepEqual(await branchPromise, { - id: "conversation-branch", - title: "新分支", - message_count: 6, - created_at: 1700000000100, - updated_at: 1700000000200, - }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: request.requestId, + agent_response: { + history_branch_resp: { + conversation: { + id: "conversation-branch", + title: "新分支", + message_count: 6, + created_at: 1700000000100, + updated_at: 1700000000200, + }, + }, + }, + }), + ); + const branched = await branchPromise; + assert.equal(branched.id, "conversation-branch"); + assert.equal(branched.title, "新分支"); + assert.equal(branched.message_count, 6); + assert.equal(branched.created_at, 1700000000100); + assert.equal(branched.updated_at, 1700000000200); resetGatewayWebSocketClient(); }); test("GatewayWebSocketClient reconnects before read requests when an authenticated socket goes stale", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); 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" }, - }); + const firstSocket = await connectAndAuth(codec); + await waitFor(() => findFrame(codec, firstSocket, "statusGet"), "initial status_get"); + const statusRequest = findFrame(codec, firstSocket, "statusGet"); + firstSocket.receiveBinary( + codec.encodeServerFrame({ + request_id: statusRequest.requestId, + status: { online: true, agent_id: "desktop-agent" }, + }), + ); await statusPromise; let mockNow = realDateNow(); @@ -1015,38 +1061,34 @@ test("GatewayWebSocketClient reconnects before read requests when an authenticat Date.now = realDateNow; - const reconnectSocket = FakeWebSocket.instances[1]; - reconnectSocket.open(); - await waitFor(() => reconnectSocket.sent.length >= 1, "stale reconnect auth envelope"); - reconnectSocket.receive({ - id: reconnectSocket.sent[0].id, - type: "response", - payload: { ok: true }, - }); + const reconnectSocket = await connectAndAuth(codec, 1); await waitFor( - () => reconnectSocket.sent.some((item) => item.type === "history.get"), + () => findAgentRequest(codec, reconnectSocket, "history_get"), "history request after stale reconnect", ); - const historyRequest = reconnectSocket.sent.find((item) => item.type === "history.get"); - assert.deepEqual(historyRequest.payload, { + const historyRequest = findAgentRequest(codec, reconnectSocket, "history_get"); + assert.deepEqual(historyRequest.json.agent_request.history_get, { conversation_id: "conversation-1", }); - const payload = { + reconnectSocket.receiveBinary( + codec.encodeServerFrame({ + request_id: historyRequest.requestId, + agent_response: { + history_get_resp: { conversation_id: "conversation-1", messages_json: "[]" }, + }, + }), + ); + + assert.deepEqual(await historyPromise, { conversation_id: "conversation-1", messages_json: "[]", total_message_count: 0, returned_message_count: 0, has_more: false, - }; - reconnectSocket.receive({ - id: historyRequest.id, - type: "response", - payload, + conversation: null, }); - - assert.deepEqual(await historyPromise, payload); } finally { Date.now = realDateNow; resetGatewayWebSocketClient(); @@ -1056,57 +1098,43 @@ test("GatewayWebSocketClient reconnects before read requests when an authenticat test("GatewayWebSocketClient retries history.get after a recoverable transport stall timeout", async () => { const realSetTimeout = setTimeout; installBrowser({ - setTimeout: (fn, delay, ...args) => - realSetTimeout(fn, delay >= 30_000 ? 0 : delay, ...args), + setTimeout: (fn, delay, ...args) => realSetTimeout(fn, delay >= 30_000 ? 0 : delay, ...args), }); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); const historyPromise = client.getHistory("conversation-1"); - const firstSocket = await connectAndAuth(); + const firstSocket = await connectAndAuth(codec); await waitFor( - () => firstSocket.sent.some((item) => item.type === "history.get"), - "initial history.get envelope", + () => findAgentRequest(codec, firstSocket, "history_get"), + "initial history_get frame", ); await waitFor(() => FakeWebSocket.instances.length === 2, "timeout recovery websocket"); - const reconnectSocket = FakeWebSocket.instances[1]; - reconnectSocket.open(); - await waitFor(() => reconnectSocket.sent.length >= 1, "timeout recovery auth envelope"); - reconnectSocket.receive({ - id: reconnectSocket.sent[0].id, - type: "response", - payload: { ok: true }, - }); + const reconnectSocket = await connectAndAuth(codec, 1); await waitFor( - () => reconnectSocket.sent.some((item) => item.type === "history.get"), - "retried history.get envelope", + () => findAgentRequest(codec, reconnectSocket, "history_get"), + "retried history_get frame", ); - const historyRequest = reconnectSocket.sent.find((item) => item.type === "history.get"); - const payload = { - conversation_id: "conversation-1", - messages_json: "[]", - total_message_count: 0, - returned_message_count: 0, - has_more: false, - }; - reconnectSocket.receive({ - id: historyRequest.id, - type: "response", - payload, - }); + const historyRequest = findAgentRequest(codec, reconnectSocket, "history_get"); + reconnectSocket.receiveBinary( + codec.encodeServerFrame({ + request_id: historyRequest.requestId, + agent_response: { + history_get_resp: { conversation_id: "conversation-1", messages_json: "[]" }, + }, + }), + ); - assert.deepEqual(await historyPromise, payload); + assert.equal((await historyPromise).conversation_id, "conversation-1"); resetGatewayWebSocketClient(); }); test("GatewayWebSocketClient suppresses transient recoverable disconnect status errors", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -1114,14 +1142,15 @@ test("GatewayWebSocketClient suppresses transient recoverable disconnect status const unsubscribe = client.subscribeStatus((status, error) => { statusEvents.push({ status, error }); }); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.some((item) => item.type === "status.get"), "status envelope"); - const statusRequest = socket.sent.find((item) => item.type === "status.get"); - socket.receive({ - id: statusRequest.id, - type: "response", - payload: { online: true, agent_id: "desktop-agent" }, - }); + const socket = await connectAndAuth(codec); + await waitFor(() => findFrame(codec, socket, "statusGet"), "status frame"); + const statusRequest = findFrame(codec, socket, "statusGet"); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: statusRequest.requestId, + status: { online: true, agent_id: "desktop-agent" }, + }), + ); await waitFor( () => statusEvents.some((event) => event.status?.online === true), "online status event", @@ -1141,40 +1170,31 @@ test("GatewayWebSocketClient suppresses transient recoverable disconnect status resetGatewayWebSocketClient(); }); -test("GatewayWebSocketClient replies to gateway websocket pings", async () => { +test("GatewayWebSocketClient replies to app-level pings with pong frames", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); const statusPromise = client.getStatus(); - const socket = await connectAndAuth(); - socket.receive({ - type: "ping", - payload: { timestamp: 123 }, - }); - await waitFor(() => socket.sent.some((item) => item.type === "pong"), "pong envelope"); - const pong = socket.sent.find((item) => item.type === "pong"); - assert.deepEqual(pong.payload, { timestamp: 123 }); - - await waitFor(() => socket.sent.some((item) => item.type === "status.get"), "status envelope"); - const statusRequest = socket.sent.find((item) => item.type === "status.get"); - socket.receive({ - id: statusRequest.id, - type: "response", - payload: { online: true, agent_id: "desktop-agent" }, - }); + const socket = await connectAndAuth(codec); + socket.receiveBinary(codec.encodeServerFrame({ ping: { timestamp: 123 } })); + await waitFor(() => findFrame(codec, socket, "pong"), "pong frame"); + const pong = findFrame(codec, socket, "pong"); + assert.equal(Number(pong.json.pong.timestamp), 123); + + await waitFor(() => findFrame(codec, socket, "statusGet"), "status frame"); + const statusRequest = findFrame(codec, socket, "statusGet"); + socket.receiveBinary( + codec.encodeServerFrame({ request_id: statusRequest.requestId, status: { online: true } }), + ); await statusPromise; resetGatewayWebSocketClient(); }); -test("GatewayWebSocketClient chatCommand sends the command envelope and parses the accept response", async () => { +test("GatewayWebSocketClient chatCommand sends the command frame and parses the accept response", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( - "src/lib/gatewaySocket.ts", - ); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -1190,21 +1210,23 @@ test("GatewayWebSocketClient chatCommand sends the command envelope and parses t selectedSystemTools: ["Bash"], }, }); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.some((item) => item.type === "chat.command"), "chat command envelope"); - const commandEnvelope = socket.sent.find((item) => item.type === "chat.command"); - assert.equal(commandEnvelope.payload.type, "chat.submit"); - assert.equal(commandEnvelope.payload.payload.message, "hello"); - assert.equal(commandEnvelope.payload.payload.conversation_id, "conversation-1"); - assert.equal(commandEnvelope.payload.payload.client_request_id, "req-1"); - assert.equal(commandEnvelope.payload.payload.queue_policy, "append"); - assert.equal(commandEnvelope.payload.payload.workdir, "/workspace/project"); - - socket.receive({ - id: commandEnvelope.id, - type: "response", - payload: { run_id: " run-1 ", conversation_id: "conversation-1", accepted_seq: 7 }, - }); + const socket = await connectAndAuth(codec); + await waitFor(() => findFrame(codec, socket, "chatCommand"), "chat command frame"); + const command = findFrame(codec, socket, "chatCommand"); + assert.equal(command.json.chat_command.type, "chat.submit"); + assert.equal(command.json.chat_command.request.message, "hello"); + assert.equal(command.json.chat_command.request.conversation_id, "conversation-1"); + assert.equal(command.json.chat_command.request.client_request_id, "req-1"); + assert.equal(command.json.chat_command.request.queue_policy, "append"); + assert.equal(command.json.chat_command.request.workdir, "/workspace/project"); + assert.deepEqual(command.json.chat_command.request.selected_system_tools, ["Bash"]); + + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: command.requestId, + chat_accepted: { run_id: " run-1 ", conversation_id: "conversation-1", accepted_seq: 7 }, + }), + ); assert.deepEqual(await commandPromise, { runId: "run-1", conversationId: "conversation-1", @@ -1215,10 +1237,7 @@ test("GatewayWebSocketClient chatCommand sends the command envelope and parses t test("GatewayWebSocketClient reconnects once and retries chatCommand with the same payload", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( - "src/lib/gatewaySocket.ts", - ); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -1228,31 +1247,30 @@ test("GatewayWebSocketClient reconnects once and retries chatCommand with the sa conversationId: "conversation-1", clientRequestId: "client-retry-1", }); - const firstSocket = await connectAndAuth(0); - await waitFor( - () => firstSocket.sent.some((item) => item.type === "chat.command"), - "first chat.command envelope", - ); - const firstCommand = firstSocket.sent.find((item) => item.type === "chat.command"); + const firstSocket = await connectAndAuth(codec, 0); + await waitFor(() => findFrame(codec, firstSocket, "chatCommand"), "first chat command frame"); + const firstCommand = findFrame(codec, firstSocket, "chatCommand"); firstSocket.close({ code: 1006, wasClean: false }); - const secondSocket = await connectAndAuth(1); - await waitFor( - () => secondSocket.sent.some((item) => item.type === "chat.command"), - "retried chat.command envelope", - ); - const retriedCommand = secondSocket.sent.find((item) => item.type === "chat.command"); - assert.deepEqual(retriedCommand.payload, firstCommand.payload); + const secondSocket = await connectAndAuth(codec, 1); + await waitFor(() => findFrame(codec, secondSocket, "chatCommand"), "retried chat command frame"); + const retriedCommand = findFrame(codec, secondSocket, "chatCommand"); + assert.deepEqual(retriedCommand.json.chat_command, firstCommand.json.chat_command); assert.equal( - retriedCommand.payload.payload.client_request_id, + retriedCommand.json.chat_command.request.client_request_id, "client-retry-1", "retry preserves the idempotency key", ); - secondSocket.receive({ - id: retriedCommand.id, - type: "response", - payload: { run_id: "run-canonical", conversation_id: "conversation-1", accepted_seq: 3 }, - }); + secondSocket.receiveBinary( + codec.encodeServerFrame({ + request_id: retriedCommand.requestId, + chat_accepted: { + run_id: "run-canonical", + conversation_id: "conversation-1", + accepted_seq: 3, + }, + }), + ); assert.equal((await commandPromise).runId, "run-canonical"); assert.equal(FakeWebSocket.instances.length, 2, "only one transparent retry is attempted"); @@ -1262,10 +1280,7 @@ test("GatewayWebSocketClient reconnects once and retries chatCommand with the sa test("GatewayWebSocketClient uses a short ACK timeout and preserves a generated client id", async () => { const timers = createManualTimers(); installBrowser({ ...timers }); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( - "src/lib/gatewaySocket.ts", - ); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -1274,29 +1289,27 @@ test("GatewayWebSocketClient uses a short ACK timeout and preserves a generated message: "timeout retry", conversationId: "conversation-1", }); - const firstSocket = await connectAndAuth(0); - await waitFor( - () => firstSocket.sent.some((item) => item.type === "chat.command"), - "first chat.command envelope", - ); - const firstCommand = firstSocket.sent.find((item) => item.type === "chat.command"); - const generatedClientRequestId = firstCommand.payload.payload.client_request_id; + const firstSocket = await connectAndAuth(codec, 0); + await waitFor(() => findFrame(codec, firstSocket, "chatCommand"), "first chat command frame"); + const firstCommand = findFrame(codec, firstSocket, "chatCommand"); + const generatedClientRequestId = firstCommand.json.chat_command.request.client_request_id; assert.match(generatedClientRequestId, /^webui-chat\.submit-/); timers.fire((timer) => timer.ms === 4_000); - const secondSocket = await connectAndAuth(1); - await waitFor( - () => secondSocket.sent.some((item) => item.type === "chat.command"), - "ACK-timeout retry envelope", - ); - const retriedCommand = secondSocket.sent.find((item) => item.type === "chat.command"); - assert.equal(retriedCommand.payload.payload.client_request_id, generatedClientRequestId); - assert.deepEqual(retriedCommand.payload, firstCommand.payload); - secondSocket.receive({ - id: retriedCommand.id, - type: "response", - payload: { run_id: "run-timeout", conversation_id: "conversation-1", accepted_seq: 1 }, - }); + const secondSocket = await connectAndAuth(codec, 1); + await waitFor(() => findFrame(codec, secondSocket, "chatCommand"), "ACK-timeout retry frame"); + const retriedCommand = findFrame(codec, secondSocket, "chatCommand"); + assert.equal( + retriedCommand.json.chat_command.request.client_request_id, + generatedClientRequestId, + ); + assert.deepEqual(retriedCommand.json.chat_command, firstCommand.json.chat_command); + secondSocket.receiveBinary( + codec.encodeServerFrame({ + request_id: retriedCommand.requestId, + chat_accepted: { run_id: "run-timeout", conversation_id: "conversation-1", accepted_seq: 1 }, + }), + ); assert.equal((await commandPromise).runId, "run-timeout"); resetGatewayWebSocketClient(); @@ -1304,76 +1317,66 @@ test("GatewayWebSocketClient uses a short ACK timeout and preserves a generated test("a detached socket's late close cannot tear down its replacement", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( - "src/lib/gatewaySocket.ts", - ); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); const firstStatus = client.getStatus(); - const firstSocket = await connectAndAuth(0); - await waitFor( - () => firstSocket.sent.some((item) => item.type === "status.get"), - "first status request", - ); - const firstStatusRequest = firstSocket.sent.find((item) => item.type === "status.get"); - firstSocket.receive({ - id: firstStatusRequest.id, - type: "response", - payload: { online: true }, - }); + const firstSocket = await connectAndAuth(codec, 0); + await waitFor(() => findFrame(codec, firstSocket, "statusGet"), "first status request"); + const firstStatusRequest = findFrame(codec, firstSocket, "statusGet"); + firstSocket.receiveBinary( + codec.encodeServerFrame({ request_id: firstStatusRequest.requestId, status: { online: true } }), + ); await firstStatus; const lateClose = firstSocket.onclose; firstSocket.close({ code: 1006, wasClean: false }); const secondStatus = client.getStatus(); - const secondSocket = await connectAndAuth(1); - await waitFor( - () => secondSocket.sent.some((item) => item.type === "status.get"), - "replacement status request", - ); + const secondSocket = await connectAndAuth(codec, 1); + await waitFor(() => findFrame(codec, secondSocket, "statusGet"), "replacement status request"); lateClose?.({ code: 1006, reason: "late old close", wasClean: false }); - const secondStatusRequest = secondSocket.sent.find((item) => item.type === "status.get"); - secondSocket.receive({ - id: secondStatusRequest.id, - type: "response", - payload: { online: true, session_id: "replacement" }, - }); + const secondStatusRequest = findFrame(codec, secondSocket, "statusGet"); + secondSocket.receiveBinary( + codec.encodeServerFrame({ + request_id: secondStatusRequest.requestId, + status: { online: true, session_id: "replacement" }, + }), + ); assert.equal((await secondStatus).session_id, "replacement"); assert.equal(FakeWebSocket.instances.length, 2); resetGatewayWebSocketClient(); }); -test("GatewayWebSocketClient cancelChat sends chat.cancel with conversation and run ids", async () => { +test("GatewayWebSocketClient cancelChat sends a cancel chat command with conversation and run ids", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( - "src/lib/gatewaySocket.ts", - ); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); const cancelPromise = client.cancelChat(" conversation-1 ", " run-9 "); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.some((item) => item.type === "chat.cancel"), "chat.cancel envelope"); - const cancelEnvelope = socket.sent.find((item) => item.type === "chat.cancel"); - assert.deepEqual(cancelEnvelope.payload, { + const socket = await connectAndAuth(codec); + await waitFor(() => findFrame(codec, socket, "chatCommand"), "chat cancel frame"); + const cancelFrame = findFrame(codec, socket, "chatCommand"); + assert.equal(cancelFrame.json.chat_command.type, "chat.cancel"); + assert.deepEqual(cancelFrame.json.chat_command.cancel, { conversation_id: "conversation-1", run_id: "run-9", }); - socket.receive({ id: cancelEnvelope.id, type: "response", payload: {} }); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: cancelFrame.requestId, + chat_cancelled: { ok: true, run_id: "run-9", conversation_id: "conversation-1" }, + }), + ); await cancelPromise; resetGatewayWebSocketClient(); }); test("GatewayWebSocketClient conversation subscriptions subscribe after auth, route pushes, and survive reconnects", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( - "src/lib/gatewaySocket.ts", - ); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -1383,36 +1386,39 @@ test("GatewayWebSocketClient conversation subscriptions subscribe after auth, ro onEvent: (event) => seen.events.push(event), }); - // Authentication is the only connection notification point. Answer each - // subscribe request with the caller's cursor plus any staged replay events. + // 鉴权是唯一的连接通知点;对每条 chat_subscribe 帧按调用方游标 + 暂存的 + // 重放事件应答。 const answeredSubscribes = new Set(); let replayEvents = []; const subscribeCalls = []; const answerSubscribes = (socket) => { - for (const envelope of socket.sent) { - if (envelope.type !== "chat.subscribe" || answeredSubscribes.has(envelope.id)) { + for (const frame of frames(codec, socket)) { + if (frame.case !== "chatSubscribe" || answeredSubscribes.has(frame.requestId)) { continue; } - answeredSubscribes.add(envelope.id); - subscribeCalls.push(envelope.payload); + answeredSubscribes.add(frame.requestId); + const payload = { + conversation_id: frame.json.chat_subscribe.conversation_id ?? "", + after_seq: Number(frame.json.chat_subscribe.after_seq ?? 0), + stream_epoch: frame.json.chat_subscribe.stream_epoch ?? "", + }; + subscribeCalls.push(payload); const events = replayEvents; replayEvents = []; const latestSeq = events.length ? events[events.length - 1].seq - : Math.max(envelope.payload.after_seq ?? 0, 2); - socket.receive({ - id: envelope.id, - type: "response", - payload: { - conversation_id: "conversation-1", - stream_epoch: "epoch-1", - latest_seq: latestSeq, - reset: false, - activity: null, - snapshot: null, - events, - }, - }); + : Math.max(payload.after_seq, 2); + socket.receiveBinary( + codec.encodeServerFrame({ + request_id: frame.requestId, + chat_subscribed: { + conversation_id: "conversation-1", + stream_epoch: "epoch-1", + latest_seq: latestSeq, + events_json: events.map((event) => codec.base64(event)), + }, + }), + ); } }; const settle = async (socket) => { @@ -1422,9 +1428,9 @@ test("GatewayWebSocketClient conversation subscriptions subscribe after auth, ro } }; - // Auth completes → the persistent subscription issues chat.subscribe. - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.some((item) => item.type === "chat.subscribe"), "chat.subscribe"); + // 鉴权完成 → 持久订阅发出 chat_subscribe。 + const socket = await connectAndAuth(codec); + await waitFor(() => findFrame(codec, socket, "chatSubscribe"), "chat_subscribe"); assert.equal(subscribeCalls.length, 0); await settle(socket); assert.ok(seen.syncs.length >= 1, "subscribe sync delivered"); @@ -1432,18 +1438,37 @@ test("GatewayWebSocketClient conversation subscriptions subscribe after auth, ro assert.equal(subscribeCalls[0].conversation_id, "conversation-1"); assert.equal(subscribeCalls[0].after_seq, 0); - // chat.event pushes route by conversation id (no subscription id). - socket.receive({ - type: "chat.event", - payload: { type: "run_started", conversation_id: "conversation-1", run_id: "run-1", seq: 3 }, + // chat_event 推送按会话 id 路由。 + const pushChatEvent = (target, payload) => { + target.receiveBinary( + codec.encodeServerFrame({ + chat_event: { + conversation_id: payload.conversation_id, + seq: payload.seq, + payload_json: codec.base64(payload), + }, + }), + ); + }; + pushChatEvent(socket, { + type: "run_started", + conversation_id: "conversation-1", + run_id: "run-1", + seq: 3, }); - socket.receive({ - type: "chat.event", - payload: { type: "token", conversation_id: "conversation-1", run_id: "run-1", seq: 4, text: "hi" }, + pushChatEvent(socket, { + type: "token", + conversation_id: "conversation-1", + run_id: "run-1", + seq: 4, + text: "hi", }); - socket.receive({ - type: "chat.event", - payload: { type: "token", conversation_id: "conversation-other", run_id: "run-x", seq: 9, text: "ignored" }, + pushChatEvent(socket, { + type: "token", + conversation_id: "conversation-other", + run_id: "run-x", + seq: 9, + text: "ignored", }); await settle(socket); assert.deepEqual( @@ -1451,8 +1476,7 @@ test("GatewayWebSocketClient conversation subscriptions subscribe after auth, ro ["run_started", "token"], ); - // Disconnect keeps the registration; the reconnect re-subscribes with the - // resume cursor and stream epoch. + // 断线保留登记;重连按 resume 游标与 epoch 重新订阅。 const syncsBeforeReconnect = seen.syncs.length; replayEvents = [ { type: "token", conversation_id: "conversation-1", run_id: "run-1", seq: 5, text: "re" }, @@ -1481,7 +1505,7 @@ test("GatewayWebSocketClient conversation subscriptions subscribe after auth, ro }; tick(); }); - const reconnectSocket = await connectAndAuth(1); + const reconnectSocket = await connectAndAuth(codec, 1); await settle(reconnectSocket); assert.ok(seen.syncs.length > syncsBeforeReconnect, "resume sync delivered"); assert.equal( @@ -1499,31 +1523,26 @@ test("GatewayWebSocketClient conversation subscriptions subscribe after auth, ro "replayed events delivered with the resume sync", ); - // chat.subscription_reset triggers another resync from the cursor. + // chat_subscription_reset 触发从游标再同步。 const subscribesBeforeReset = subscribeCalls.length; - reconnectSocket.receive({ - type: "chat.subscription_reset", - payload: { conversation_id: "conversation-1" }, - }); + reconnectSocket.receiveBinary( + codec.encodeServerFrame({ + chat_subscription_reset: { conversation_id: "conversation-1" }, + }), + ); await settle(reconnectSocket); assert.ok(subscribeCalls.length > subscribesBeforeReset, "reset re-subscribed"); assert.equal(subscribeCalls[subscribesBeforeReset].after_seq, 6); - // Cleanup unsubscribes on the wire. + // 清理时在线退订。 cleanup(); - await waitFor( - () => reconnectSocket.sent.some((item) => item.type === "chat.unsubscribe"), - "chat.unsubscribe", - ); + await waitFor(() => findFrame(codec, reconnectSocket, "chatUnsubscribe"), "chat_unsubscribe"); resetGatewayWebSocketClient(); }); -test("GatewayWebSocketClient fans chat.activity and chat.command_update out to listeners", async () => { +test("GatewayWebSocketClient fans chat_activity and chat_command_update out to listeners", async () => { installBrowser(); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( - "src/lib/gatewaySocket.ts", - ); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -1533,27 +1552,32 @@ test("GatewayWebSocketClient fans chat.activity and chat.command_update out to l client.subscribeChatCommandUpdates((update) => commandUpdates.push(update)); const statusPromise = client.getStatus(); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.length >= 2, "status envelope"); - socket.receive({ id: socket.sent[1].id, type: "response", payload: { online: true } }); + const socket = await connectAndAuth(codec); + await waitFor(() => findFrame(codec, socket, "statusGet"), "status frame"); + const statusRequest = findFrame(codec, socket, "statusGet"); + socket.receiveBinary( + codec.encodeServerFrame({ request_id: statusRequest.requestId, status: { online: true } }), + ); await statusPromise; - socket.receive({ - type: "chat.activity", - payload: { - conversation_id: "conversation-1", - run_id: "run-1", - running: true, - state: "running", - workdir: "/workspace/project", - client_request_id: "req-42", - updated_at: 1234, - }, - }); - socket.receive({ - type: "chat.activity", - payload: { conversation_id: "", running: true }, - }); + socket.receiveBinary( + codec.encodeServerFrame({ + chat_activity: { + conversation_id: "conversation-1", + run_id: "run-1", + running: true, + state: "running", + workdir: "/workspace/project", + client_request_id: "req-42", + updated_at_ms: 1234, + }, + }), + ); + socket.receiveBinary( + codec.encodeServerFrame({ + chat_activity: { conversation_id: "", running: true }, + }), + ); assert.equal(activityEvents.length, 1, "malformed activity payloads are dropped"); assert.deepEqual(activityEvents[0], { conversationId: "conversation-1", @@ -1565,19 +1589,21 @@ test("GatewayWebSocketClient fans chat.activity and chat.command_update out to l updatedAt: 1234, }); - socket.receive({ - type: "chat.command_update", - payload: { - run_id: "run-1", - client_request_id: "req-1", - conversation_id: "conversation-9", - phase: "bound", - }, - }); - socket.receive({ - type: "chat.command_update", - payload: { run_id: "run-1", phase: "unknown-phase" }, - }); + socket.receiveBinary( + codec.encodeServerFrame({ + chat_command_update: { + run_id: "run-1", + client_request_id: "req-1", + conversation_id: "conversation-9", + phase: "bound", + }, + }), + ); + socket.receiveBinary( + codec.encodeServerFrame({ + chat_command_update: { run_id: "run-1", phase: "unknown-phase" }, + }), + ); assert.equal(commandUpdates.length, 1, "unknown phases are dropped"); assert.deepEqual(commandUpdates[0], { runId: "run-1", @@ -1621,12 +1647,10 @@ function createManualTimers() { }; } -test("GatewayWebSocketClient applies pushed status.event frames and polls slowly as fallback", async () => { +test("GatewayWebSocketClient applies pushed status frames and polls slowly as fallback", async () => { const timers = createManualTimers(); installBrowser({ ...timers }); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = - loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -1639,21 +1663,17 @@ test("GatewayWebSocketClient applies pushed status.event frames and polls slowly `status poll interval registered at 30s, got delays ${JSON.stringify(timers.delays())}`, ); - const socket = await connectAndAuth(); - // The initial poll fired by subscribeStatus rides the fresh socket. - await waitFor( - () => socket.sent.some((envelope) => envelope.type === "status.get"), - "initial status.get", - ); + const socket = await connectAndAuth(codec); + // subscribeStatus 触发的首轮轮询搭乘新连接。 + await waitFor(() => findFrame(codec, socket, "statusGet"), "initial status_get"); - socket.receive({ - type: "status.event", - payload: { online: true, agent_id: "desktop-agent" }, - }); - assert.equal(statuses.at(-1)?.status?.online, true, "pushed status.event reaches listeners"); + socket.receiveBinary( + codec.encodeServerFrame({ status: { online: true, agent_id: "desktop-agent" } }), + ); + assert.equal(statuses.at(-1)?.status?.online, true, "pushed status frame reaches listeners"); assert.equal(statuses.at(-1)?.error, null); - socket.receive({ type: "status.event", payload: { online: false } }); + socket.receiveBinary(codec.encodeServerFrame({ status: { online: false } })); assert.equal(statuses.at(-1)?.status?.online, false, "offline push reaches listeners"); resetGatewayWebSocketClient(); }); @@ -1661,9 +1681,7 @@ test("GatewayWebSocketClient applies pushed status.event frames and polls slowly test("GatewayWebSocketClient defers offline verdicts while hidden and reconciles on wake", async () => { const timers = createManualTimers(); installBrowser({ ...timers }); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = - loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -1671,17 +1689,16 @@ test("GatewayWebSocketClient defers offline verdicts while hidden and reconciles client.subscribeStatus((status, error) => { statuses.push({ status, error }); }); - const socket = await connectAndAuth(); - socket.receive({ type: "status.event", payload: { online: true } }); + const socket = await connectAndAuth(codec); + socket.receiveBinary(codec.encodeServerFrame({ status: { online: true } })); assert.equal(statuses.at(-1)?.status?.online, true); - // Tab goes hidden, then the socket drops (e.g. the browser froze the page - // long enough for a proxy to cut the link). + // 标签页转后台后连接断开(如冻结期间代理掐断链路)。 globalThis.document.visibilityState = "hidden"; const offlineCountBefore = statuses.filter((s) => s.status?.online === false).length; socket.close(); - // The 15s reconnect notice fires while hidden: no offline paint. + // 15s 重连提示在后台触发:不得涂画离线态。 timers.fire((timer) => timer.ms === 15_000); assert.equal( statuses.filter((s) => s.status?.online === false).length, @@ -1689,18 +1706,16 @@ test("GatewayWebSocketClient defers offline verdicts while hidden and reconciles "hidden tab must not paint offline from throttled timers", ); - // Tab returns: wake handler reconnects; the offline verdict stays deferred - // until the post-wake reconnect + status refresh settles. + // 回前台:唤醒处理器重连;离线判定推迟到重连 + 状态刷新落定。 globalThis.document.visibilityState = "visible"; globalThis.document.dispatchEvent({ type: "visibilitychange" }); timers.fire((timer) => timer.ms === 0); // armed reconnect timer - const socket2 = await connectAndAuth(1); - await waitFor( - () => socket2.sent.some((envelope) => envelope.type === "status.get"), - "post-wake status refresh", + const socket2 = await connectAndAuth(codec, 1); + await waitFor(() => findFrame(codec, socket2, "statusGet"), "post-wake status refresh"); + const statusReq = findFrame(codec, socket2, "statusGet"); + socket2.receiveBinary( + codec.encodeServerFrame({ request_id: statusReq.requestId, status: { online: true } }), ); - const statusReq = socket2.sent.find((envelope) => envelope.type === "status.get"); - socket2.receive({ id: statusReq.id, type: "response", payload: { online: true } }); await waitFor(() => statuses.at(-1)?.status?.online === true, "post-wake online status"); assert.equal( statuses.filter((s) => s.status?.online === false).length, @@ -1713,9 +1728,7 @@ test("GatewayWebSocketClient defers offline verdicts while hidden and reconciles test("GatewayWebSocketClient paints offline when the post-wake reconnect notice expires visible", async () => { const timers = createManualTimers(); installBrowser({ ...timers }); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = - loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); @@ -1723,8 +1736,8 @@ test("GatewayWebSocketClient paints offline when the post-wake reconnect notice client.subscribeStatus((status, error) => { statuses.push({ status, error }); }); - const socket = await connectAndAuth(); - socket.receive({ type: "status.event", payload: { online: true } }); + const socket = await connectAndAuth(codec); + socket.receiveBinary(codec.encodeServerFrame({ status: { online: true } })); globalThis.document.visibilityState = "hidden"; socket.close(); @@ -1733,7 +1746,7 @@ test("GatewayWebSocketClient paints offline when the post-wake reconnect notice globalThis.document.visibilityState = "visible"; globalThis.document.dispatchEvent({ type: "visibilitychange" }); - // Reconnect never succeeds; the re-armed notice expires while visible. + // 重连始终不成功;重新武装的提示在前台超时。 timers.fire((timer) => timer.ms === 15_000); const last = statuses.at(-1); assert.equal(last?.status?.online, false, "failed wake reconcile paints offline"); @@ -1744,33 +1757,30 @@ test("GatewayWebSocketClient paints offline when the post-wake reconnect notice test("GatewayWebSocketClient refreshes status immediately on wake with a healthy socket", async () => { const timers = createManualTimers(); installBrowser({ ...timers }); - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = - loader.loadModule("src/lib/gatewaySocket.ts"); + const { codec, getGatewayWebSocketClient, resetGatewayWebSocketClient } = loadGatewaySocket(); resetGatewayWebSocketClient(); const client = getGatewayWebSocketClient("token"); client.subscribeStatus(() => {}); - const socket = await connectAndAuth(); - await waitFor( - () => socket.sent.some((envelope) => envelope.type === "status.get"), - "initial status.get", - ); - const initialStatusRequests = socket.sent.filter( - (envelope) => envelope.type === "status.get", + const socket = await connectAndAuth(codec); + await waitFor(() => findFrame(codec, socket, "statusGet"), "initial status_get"); + const initialStatusRequests = frames(codec, socket).filter( + (frame) => frame.case === "statusGet", ).length; - const statusReq = socket.sent.find((envelope) => envelope.type === "status.get"); - socket.receive({ id: statusReq.id, type: "response", payload: { online: true } }); - // Let the in-flight refreshStatus settle (its finally runs in a microtask) - // so the wake-triggered refresh is not swallowed by the in-flight guard. + const statusReq = findFrame(codec, socket, "statusGet"); + socket.receiveBinary( + codec.encodeServerFrame({ request_id: statusReq.requestId, status: { online: true } }), + ); + // 等在途 refreshStatus 落定(finally 在微任务里跑),避免唤醒触发的刷新 + // 被 in-flight 守卫吞掉。 await new Promise((resolve) => setTimeout(resolve, 0)); globalThis.window.dispatchEvent({ type: "focus" }); await waitFor( () => - socket.sent.filter((envelope) => envelope.type === "status.get").length > + frames(codec, socket).filter((frame) => frame.case === "statusGet").length > initialStatusRequests, - "wake-triggered status.get", + "wake-triggered status_get", ); resetGatewayWebSocketClient(); }); diff --git a/crates/agent-gateway/web/biome.json b/crates/agent-gateway/web/biome.json index 6c1ea5e54..37f08fd5d 100644 --- a/crates/agent-gateway/web/biome.json +++ b/crates/agent-gateway/web/biome.json @@ -9,7 +9,8 @@ "includes": [ "src/**", "!!**/dist", - "!!**/node_modules" + "!!**/node_modules", + "!src/lib/proto/gen/**" ] }, "formatter": { diff --git a/crates/agent-gateway/web/package.json b/crates/agent-gateway/web/package.json index aa956cbb6..0c9926040 100644 --- a/crates/agent-gateway/web/package.json +++ b/crates/agent-gateway/web/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@base-ui/react": "^1.6.0", + "@bufbuild/protobuf": "^2.12.1", "@earendil-works/pi-ai": "^0.80.6", "@git-diff-view/file": "^0.1.3", "@git-diff-view/react": "^0.1.3", @@ -46,6 +47,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.15", + "@bufbuild/protoc-gen-es": "2.12.1", "@iconify-json/logos": "^1.2.11", "@iconify-json/lucide": "^1.2.108", "@iconify-json/material-icon-theme": "1.2.67", diff --git a/crates/agent-gateway/web/pnpm-lock.yaml b/crates/agent-gateway/web/pnpm-lock.yaml index eca977ba7..3537347ef 100644 --- a/crates/agent-gateway/web/pnpm-lock.yaml +++ b/crates/agent-gateway/web/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@base-ui/react': specifier: ^1.6.0 version: 1.6.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@bufbuild/protobuf': + specifier: ^2.12.1 + version: 2.12.1 '@earendil-works/pi-ai': specifier: ^0.80.6 version: 0.80.6(ws@8.21.0)(zod@4.4.3) @@ -102,6 +105,9 @@ importers: '@biomejs/biome': specifier: ^2.4.15 version: 2.5.2 + '@bufbuild/protoc-gen-es': + specifier: 2.12.1 + version: 2.12.1(@bufbuild/protobuf@2.12.1) '@iconify-json/logos': specifier: ^1.2.11 version: 1.2.11 @@ -421,6 +427,22 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bufbuild/protobuf@2.12.1': + resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} + + '@bufbuild/protoc-gen-es@2.12.1': + resolution: {integrity: sha512-SWa7XvRYRouMo+vBQmpNFZ+ZEqQ8AC0LpL4QWAo1gvstLhFh/Y7Nf/a+MK7ZxDq5LZSThwfk974L1sFxO3OaGw==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + '@bufbuild/protobuf': 2.12.1 + peerDependenciesMeta: + '@bufbuild/protobuf': + optional: true + + '@bufbuild/protoplugin@2.12.1': + resolution: {integrity: sha512-PY58KxQVAD1BnnKtStOctsMoegEVGfBnY5AOqVQOIu711nA13oYtTqJM8df5lUQg2J1DR3XxUXptE+fWX5oLdA==} + '@chevrotain/cst-dts-gen@12.0.0': resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==} @@ -1406,6 +1428,11 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript/vfs@1.6.4': + resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==} + peerDependencies: + typescript: '*' + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -2713,6 +2740,11 @@ packages: typebox@1.1.38: resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.2: resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} engines: {node: '>=14.17'} @@ -3333,6 +3365,24 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@bufbuild/protobuf@2.12.1': {} + + '@bufbuild/protoc-gen-es@2.12.1(@bufbuild/protobuf@2.12.1)': + dependencies: + '@bufbuild/protoplugin': 2.12.1 + optionalDependencies: + '@bufbuild/protobuf': 2.12.1 + transitivePeerDependencies: + - supports-color + + '@bufbuild/protoplugin@2.12.1': + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@typescript/vfs': 1.6.4(typescript@5.4.5) + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + '@chevrotain/cst-dts-gen@12.0.0': dependencies: '@chevrotain/gast': 12.0.0 @@ -4339,6 +4389,13 @@ snapshots: '@types/unist@3.0.3': {} + '@typescript/vfs@1.6.4(typescript@5.4.5)': + dependencies: + debug: 4.4.3 + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + '@ungap/structured-clone@1.3.0': {} '@upsetjs/venn.js@2.0.0': @@ -5999,6 +6056,8 @@ snapshots: typebox@1.1.38: {} + typescript@5.4.5: {} + typescript@6.0.2: {} ufo@1.6.3: {} diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index 2ebf0b876..bba668048 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -48,6 +48,15 @@ import type { WorkspaceActivity, WorkspaceActivityEventPayload, } from "@/lib/workspace-activity/types"; +import type { DecodedServerFrame } from "./gatewaySocketV2/adapters"; +import { + decodeServerFrame, + decodeServerFrameBinary, + encodeHelloFrame, + encodePongFrame, + encodeRequestFrame, + GATEWAY_V2_SUBPROTOCOL, +} from "./gatewaySocketV2/adapters"; import type { AgentStatus, ChatQueueResponse, @@ -69,13 +78,6 @@ import type { RunningConversationSummary, } from "./gatewayTypes"; -type GatewaySocketEnvelope = { - id?: string; - type: string; - payload?: unknown; - error?: string; -}; - type StatusListener = (status: AgentStatus | null, error: string | null) => void; type HistoryListener = (event: GatewayHistoryEvent) => void; type SettingsListener = (event: GatewaySettingsSyncPayload) => void; @@ -545,7 +547,8 @@ function buildWebSocketUrl() { } const url = new URL(origin); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - url.pathname = "/ws"; + // v2 统一线协议端点(WebSocket + Protobuf 二进制帧)。 + url.pathname = "/ws/v2"; url.search = ""; url.hash = ""; return url.toString(); @@ -1263,6 +1266,8 @@ export class GatewayWebSocketClient { private reconnectNoticeTimer: number | null = null; private reconnectAttempt = 0; private reconnecting = false; + // hello 判定鉴权失败(随后 4401 关闭):置位抑制自动重连,显式新连接尝试会清除。 + private authRejected = false; private lastForegroundWakeupAt = 0; // A hidden tab must not paint offline state the user cannot see from // throttled timers; the verdict is deferred until a foreground recheck. @@ -2120,7 +2125,12 @@ export class GatewayWebSocketClient { if (filter?.cwdEmpty === true) { payload.cwd_empty = true; } - return this.requestWithRecovery("history.list", payload); + // running_conversations v1 由网关顺带附上,v2 改由 chat_activities 帧提供:并行取回合并,返回形状不变。 + const [list, runningConversations] = await Promise.all([ + this.requestWithRecovery("history.list", payload), + this.listChatActivities().catch(() => [] as RunningConversationSummary[]), + ]); + return { ...list, running_conversations: runningConversations }; } async listHistoryWorkdirs(): Promise { @@ -2523,6 +2533,10 @@ export class GatewayWebSocketClient { if (!this.shouldMaintainConnection()) { return; } + // 鉴权已被服务端拒绝:坏 token 的自动重连只会撞上同一个 4401。 + if (this.authRejected) { + return; + } if (this.socket?.readyState === WebSocket.OPEN && this.authenticated) { return; } @@ -2753,11 +2767,7 @@ export class GatewayWebSocketClient { }); try { - this.sendEnvelope({ - id: requestId, - type, - payload, - }); + this.sendFrame(encodeRequestFrame(requestId, type, payload)); } catch (error) { host.clearTimeout(timeoutId); this.pending.delete(requestId); @@ -2788,7 +2798,10 @@ export class GatewayWebSocketClient { const socketUrl = buildWebSocketUrl(); let reconnectAfterTimeout = false; this.connectPromise = new Promise((resolve, reject) => { - const socket = new WebSocket(socketUrl); + // 显式连接尝试清除鉴权失败标记;自动重连循环不会反复敲门坏 token。 + this.authRejected = false; + const socket = new WebSocket(socketUrl, GATEWAY_V2_SUBPROTOCOL); + socket.binaryType = "arraybuffer"; const host = getRuntimeHost(); let settled = false; let authTimeoutId: number | null = null; @@ -2900,13 +2913,7 @@ export class GatewayWebSocketClient { timeoutId: authTimeoutId, }); - this.sendEnvelope({ - id: authId, - type: "auth", - payload: { - token: this.token, - }, - }); + this.sendFrame(encodeHelloFrame(authId, this.token)); }; }).finally(() => { this.connectPromise = null; @@ -2918,137 +2925,172 @@ export class GatewayWebSocketClient { await this.connectPromise; } - private sendEnvelope(envelope: GatewaySocketEnvelope) { + // sendFrame 发送一帧已编码的 v2 二进制帧。 + private sendFrame(frame: Uint8Array) { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { throw new Error("Gateway WebSocket is not connected"); } - this.socket.send(JSON.stringify(envelope)); + this.socket.send(frame); } - private handleMessage(raw: string) { + private handleMessage(raw: unknown) { this.markInboundActivity(); - let envelope: GatewaySocketEnvelope; + // v2 链路只承载二进制帧;文本帧仅计入存活。 + if (!(raw instanceof ArrayBuffer) && !ArrayBuffer.isView(raw)) { + return; + } + let decoded: DecodedServerFrame | null; try { - envelope = JSON.parse(raw) as GatewaySocketEnvelope; + decoded = decodeServerFrame( + decodeServerFrameBinary(raw instanceof ArrayBuffer ? raw : (raw as Uint8Array)), + { agentOnline: this.lastStatus?.online === true }, + ); } catch { return; } + if (!decoded) { + return; + } - const requestId = typeof envelope.id === "string" ? envelope.id : ""; - if (envelope.type === "ping") { + if (decoded.kind === "hello") { + const pending = this.pending.get(decoded.requestId); + if (!pending) { + return; + } + const host = getRuntimeHost(); + host.clearTimeout(pending.timeoutId); + this.pending.delete(decoded.requestId); + if (decoded.ok) { + pending.resolve({ ok: true }); + } else { + // 服务端将以 4401 关闭:标记鉴权失败阻断自动重连,交由上层以 v1 同款鉴权错误 UX 呈现。 + this.authRejected = true; + pending.reject(new Error(decoded.message || "unauthorized")); + } + return; + } + + if (decoded.kind === "ping") { + // 应用层心跳:回送 pong(镜像 v1 的 JSON ping/pong)。 try { - this.sendEnvelope({ - id: this.nextRequestId("pong"), - type: "pong", - payload: - envelope.payload && typeof envelope.payload === "object" - ? envelope.payload - : { timestamp: Date.now() }, - }); + this.sendFrame(encodePongFrame(decoded.timestamp || Date.now())); } catch { // The close handler will drive reconnect if the socket is already gone. } return; } - if (envelope.type === "history.event") { - const event = envelope.payload as GatewayHistoryEvent; + if (decoded.kind === "event") { + this.handleEvent(decoded.type, decoded.payload); + return; + } + + const pending = decoded.requestId ? this.pending.get(decoded.requestId) : null; + if (!pending) { + return; + } + const host = getRuntimeHost(); + host.clearTimeout(pending.timeoutId); + this.pending.delete(decoded.requestId); + if (decoded.kind === "error") { + pending.reject(new Error(decoded.message || "Request failed")); + return; + } + pending.resolve(decoded.payload); + } + + // 分发广播事件;payload 已由适配层还原为 v1 JSON 线格式,既有归一化器原样复用。 + private handleEvent(type: string, payload: unknown) { + if (type === "history.event") { + const event = payload as GatewayHistoryEvent; if (event?.kind === "upsert" || event?.kind === "delete") { this.emitHistory(event); } return; } - if (envelope.type === "settings.event") { - const event = envelope.payload as GatewaySettingsSyncPayload; + if (type === "settings.event") { + const event = payload as GatewaySettingsSyncPayload; if (event && typeof event === "object") { this.emitSettings(event); } return; } - if (envelope.type === "terminal.event") { - const event = normalizeTerminalEvent(envelope.payload as RawTerminalEvent); + if (type === "terminal.event") { + const event = normalizeTerminalEvent(payload as RawTerminalEvent); if (event) { this.emitTerminal(event); } return; } - if (envelope.type === "sftp.event") { - const event = normalizeSftpTransferEvent(envelope.payload as RawSftpEvent); + if (type === "sftp.event") { + const event = normalizeSftpTransferEvent(payload as RawSftpEvent); if (event) { this.emitSftpTransfer(event); } return; } - if (envelope.type === "process.state") { - const payload = - envelope.payload && typeof envelope.payload === "object" - ? (envelope.payload as RawManagedProcessStatePayload) - : null; - if (payload) { - this.emitProcessState(normalizeManagedProcessState(payload)); + if (type === "process.state") { + const statePayload = + payload && typeof payload === "object" ? (payload as RawManagedProcessStatePayload) : null; + if (statePayload) { + this.emitProcessState(normalizeManagedProcessState(statePayload)); } return; } - if (envelope.type === "tunnel.state") { - const payload = - envelope.payload && typeof envelope.payload === "object" - ? (envelope.payload as RawTunnelStatePayload) - : null; - if (payload) { - this.emitTunnelState(normalizeTunnelStateSnapshot(payload)); + if (type === "tunnel.state") { + const statePayload = + payload && typeof payload === "object" ? (payload as RawTunnelStatePayload) : null; + if (statePayload) { + this.emitTunnelState(normalizeTunnelStateSnapshot(statePayload)); } return; } - if (envelope.type === "status.event") { - const payload = - envelope.payload && typeof envelope.payload === "object" - ? (envelope.payload as AgentStatus) - : null; - if (payload && typeof payload.online === "boolean") { + if (type === "status.event") { + const statusPayload = + payload && typeof payload === "object" ? (payload as AgentStatus) : null; + if (statusPayload && typeof statusPayload.online === "boolean") { this.clearReconnectNoticeTimer(); - this.emitStatus(payload, null); + this.emitStatus(statusPayload, null); } return; } - if (envelope.type === "workspace.activity") { - const payload = - envelope.payload && typeof envelope.payload === "object" - ? (envelope.payload as RawWorkspaceActivityPayload) - : null; - const activity = payload ? normalizeWorkspaceActivity(payload) : null; + if (type === "workspace.activity") { + const activityPayload = + payload && typeof payload === "object" ? (payload as RawWorkspaceActivityPayload) : null; + const activity = activityPayload ? normalizeWorkspaceActivity(activityPayload) : null; if (activity) { this.emitWorkspaceActivity(activity); } return; } - if (envelope.type === "chat_queue.event") { - const snapshot = normalizeChatQueueEvent(envelope.payload as RawChatQueueEvent); + if (type === "chat_queue.event") { + const snapshot = normalizeChatQueueEvent(payload as RawChatQueueEvent); if (snapshot) { this.emitChatQueue(snapshot); } return; } - if (envelope.type === "chat.event") { - this.conversationStreams.handleChatEvent(envelope.payload); + if (type === "chat.event") { + this.conversationStreams.handleChatEvent(payload); return; } - if (envelope.type === "chat.subscription_reset") { - this.conversationStreams.handleSubscriptionReset(envelope.payload); + if (type === "chat.subscription_reset") { + this.conversationStreams.handleSubscriptionReset(payload); return; } - if (envelope.type === "chat.activity") { - const event = normalizeActivityEvent(envelope.payload); + if (type === "chat.activity") { + const event = normalizeActivityEvent(payload); if (event) { for (const listener of this.chatActivityListeners) { listener(event); @@ -3057,33 +3099,14 @@ export class GatewayWebSocketClient { return; } - if (envelope.type === "chat.command_update") { - const update = normalizeCommandUpdate(envelope.payload); + if (type === "chat.command_update") { + const update = normalizeCommandUpdate(payload); if (update) { for (const listener of this.chatCommandUpdateListeners) { listener(update); } } - return; } - - const pending = requestId ? this.pending.get(requestId) : null; - if (!pending) { - return; - } - - const host = getRuntimeHost(); - host.clearTimeout(pending.timeoutId); - this.pending.delete(requestId); - - if (envelope.type === "error") { - pending.reject( - new Error(typeof envelope.error === "string" ? envelope.error : "Request failed"), - ); - return; - } - - pending.resolve(envelope.payload); } private handleDisconnect(error: Error) { diff --git a/crates/agent-gateway/web/src/lib/gatewaySocketV2/adapters.ts b/crates/agent-gateway/web/src/lib/gatewaySocketV2/adapters.ts new file mode 100644 index 000000000..5bf02bc7c --- /dev/null +++ b/crates/agent-gateway/web/src/lib/gatewaySocketV2/adapters.ts @@ -0,0 +1,1680 @@ +// v2 线协议适配层:把 v1「请求类型字符串 + snake_case JSON 载荷」译为 v2 protobuf 帧,并把服务端帧 +// 还原为 v1 JSON 线格式等价对象(形状以 Go 侧 websocket_payloads.go / websocket_*_handlers.go 为准), +// 使 gatewaySocket 公开 API、归一化器与上层 UI 不感知协议替换。 +// bigint 边界:本文件是 64 位整数(生成代码映射为 bigint)的唯一出入口——入站一律 Number()(均为 +// 时间戳/计数,远小于 2^53 无精度损失),出站 BigInt() 收窄;适配层之外不允许出现 bigint。 +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import type { + AgentEnvelope, + ChatQueueEvent, + ConversationSummary, + GatewayEnvelope, + HistoryShareStatus, + HistorySyncEvent, + ManagedProcessResponse, + ManagedProcessSnapshot, + SettingsSyncEvent, + SftpEntry, + SftpEvent, + SftpResponse, + SftpTransfer, + TerminalEvent, + TerminalResponse, + TerminalSession, + TerminalSshTabsSnapshot, + TerminalStreamFrame, + TunnelHealth, + TunnelStateSnapshot, + WorkspaceActivityEvent, +} from "@/lib/proto/gen/proto/v1/gateway_pb"; +import { + CancelChatRequestSchema, + ChatCommandRequestSchema, + ChatMessageRefSchema, + ChatQueueRequestSchema, + ChatRequestSchema, + ChatRuntimeControlsSchema, + ChatSelectedModelSchema, + ChatUploadedFileSchema, + CronManageRequestSchema, + FileMentionListRequestSchema, + FsCreateDirRequestSchema, + FsCreateProjectFolderRequestSchema, + FsDeleteRequestSchema, + FsListDirsRequestSchema, + FsListRequestSchema, + FsReadEditableTextRequestSchema, + FsReadWorkspaceImageRequestSchema, + FsRenameRequestSchema, + FsRootsRequestSchema, + FsWriteTextRequestSchema, + GatewayEnvelopeSchema, + GitRequestSchema, + HistoryBranchRequestSchema, + HistoryDeleteRequestSchema, + HistoryGetRequestSchema, + HistoryListRequestSchema, + HistoryPinRequestSchema, + HistoryPrefixRequestSchema, + HistoryRenameRequestSchema, + HistoryShareGetRequestSchema, + HistoryShareSetRequestSchema, + HistoryWorkdirsRequestSchema, + ManagedProcessRequestSchema, + MemoryManageRequestSchema, + ProviderListRequestSchema, + ProviderModelsRequestSchema, + SettingsGetRequestSchema, + SettingsResetSshKnownHostRequestSchema, + SettingsUpdateRequestSchema, + SftpRequestSchema, + SkillFilesListRequestSchema, + SkillManageRequestSchema, + SkillMetadataReadRequestSchema, + SkillTextReadRequestSchema, + TerminalRequestSchema, + TerminalStreamFrameSchema, + TunnelMutationSchema, + UploadedImagePreviewRequestSchema, +} from "@/lib/proto/gen/proto/v1/gateway_pb"; +import type { + ChatActivityEvent, + ChatCommandUpdate, + ChatRunActivity, + ChatRunSnapshot, + ChatSubscribeResult, + StatusEvent, + TerminalServerFrame, + WebClientFrame, + WebServerFrame, +} from "@/lib/proto/gen/proto/v2/gateway_ws_pb"; +import { + ChatActivitiesRequestSchema, + ChatPrepareRequestSchema, + ChatSubscribeRequestSchema, + ChatUnsubscribeRequestSchema, + ClientHelloSchema, + ClientRole, + PongFrameSchema, + StatusGetRequestSchema, + TerminalClientFrameSchema, + TerminalServerFrameSchema, + WebClientFrameSchema, + WebServerFrameSchema, + WorkspaceSubscribeRequestSchema, + WorkspaceUnsubscribeRequestSchema, +} from "@/lib/proto/gen/proto/v2/gateway_ws_pb"; + +// v2 WebSocket 子协议名(服务端必须回显)。 +export const GATEWAY_V2_SUBPROTOCOL = "liveagent.v2.pb"; +// ClientHello.protocol_version 的当前取值。 +export const GATEWAY_V2_PROTOCOL_VERSION = 2; + +const textDecoder = new TextDecoder(); + +type J = Record; + +// --------------------------------------------------------------------------- +// 基础读写小工具(入站 JSON 载荷是 unknown,出站 proto 需要窄化类型) +// --------------------------------------------------------------------------- + +function rec(value: unknown): J { + return value && typeof value === "object" ? (value as J) : {}; +} + +function str(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function trimStr(value: unknown): string { + return str(value).trim(); +} + +// 32 位整数出站:非法值一律回落为 0(与 v1 服务端解码后的零值一致)。 +function n32(value: unknown): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.trunc(parsed) : 0; +} + +function bool(value: unknown): boolean { + return value === true; +} + +function optBool(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +// 64 位整数出站边界:number → bigint。 +function toI64(value: unknown): bigint { + const parsed = Number(value); + return Number.isFinite(parsed) ? BigInt(Math.trunc(parsed)) : 0n; +} + +// 64 位整数入站边界:bigint → number(详见文件头注释)。 +function num(value: number | bigint | undefined): number { + return Number(value ?? 0); +} + +function parseJson(text: string): unknown { + return JSON.parse(text); +} + +// WireBytes:可直接投递给 WebSocket.send 的二进制帧。toBinary 的泛型参数是 ArrayBufferLike, +// 运行时总是普通 ArrayBuffer,此处统一收窄避免各调用点重复断言。 +export type WireBytes = Uint8Array; + +function wireBytes(bytes: Uint8Array): WireBytes { + return bytes as WireBytes; +} + +// 网关本地/解码期错误,等价于 v1 的 type:"error" 信封。 +class GatewayFrameError extends Error {} + +function frameError(message: string): never { + throw new GatewayFrameError(message); +} + +// --------------------------------------------------------------------------- +// 出站:hello / pong / 请求编码 +// --------------------------------------------------------------------------- + +export function encodeHelloFrame(requestId: string, token: string): WireBytes { + const frame = create(WebClientFrameSchema, { + requestId, + payload: { + case: "hello", + value: create(ClientHelloSchema, { + protocolVersion: GATEWAY_V2_PROTOCOL_VERSION, + role: ClientRole.BROWSER, + token, + clientName: "webui", + clientVersion: "", + }), + }, + }); + return wireBytes(toBinary(WebClientFrameSchema, frame)); +} + +export function encodePongFrame(timestamp: number): WireBytes { + const frame = create(WebClientFrameSchema, { + payload: { case: "pong", value: create(PongFrameSchema, { timestamp: toI64(timestamp) }) }, + }); + return wireBytes(toBinary(WebClientFrameSchema, frame)); +} + +// 把 v1 请求类型字符串 + JSON 载荷编码为一帧 WebClientFrame;未知类型抛错(调用方 reject)。 +export function encodeRequestFrame(requestId: string, type: string, payload: unknown): WireBytes { + return wireBytes(toBinary(WebClientFrameSchema, buildRequestFrame(requestId, type, payload))); +} + +function buildRequestFrame(requestId: string, type: string, payload: unknown): WebClientFrame { + const body = rec(payload); + switch (type) { + case "status.get": + return webFrame(requestId, "statusGet", create(StatusGetRequestSchema, {})); + case "chat.prepare": + return webFrame( + requestId, + "chatPrepare", + create(ChatPrepareRequestSchema, { reason: str(body.reason) }), + ); + case "chat.command": + return webFrame(requestId, "chatCommand", buildChatCommand(body)); + case "chat.cancel": + return webFrame( + requestId, + "chatCommand", + create(ChatCommandRequestSchema, { + type: "chat.cancel", + cancel: create(CancelChatRequestSchema, { + conversationId: trimStr(body.conversation_id), + runId: trimStr(body.run_id), + }), + }), + ); + case "chat.subscribe": + return webFrame( + requestId, + "chatSubscribe", + create(ChatSubscribeRequestSchema, { + conversationId: trimStr(body.conversation_id), + afterSeq: toI64(body.after_seq), + streamEpoch: str(body.stream_epoch), + }), + ); + case "chat.unsubscribe": + return webFrame( + requestId, + "chatUnsubscribe", + create(ChatUnsubscribeRequestSchema, { conversationId: trimStr(body.conversation_id) }), + ); + case "chat.activities": + return webFrame(requestId, "chatActivities", create(ChatActivitiesRequestSchema, {})); + case "workspace.subscribe": + return webFrame( + requestId, + "workspaceSubscribe", + create(WorkspaceSubscribeRequestSchema, { workdir: trimStr(body.workdir) }), + ); + case "workspace.unsubscribe": + return webFrame( + requestId, + "workspaceUnsubscribe", + create(WorkspaceUnsubscribeRequestSchema, { workdir: trimStr(body.workdir) }), + ); + default: + return create(WebClientFrameSchema, { + requestId, + payload: { case: "agentRequest", value: buildAgentRequest(type, body) }, + }); + } +} + +type WebFrameCase = + | "statusGet" + | "chatCommand" + | "chatPrepare" + | "chatSubscribe" + | "chatUnsubscribe" + | "chatActivities" + | "workspaceSubscribe" + | "workspaceUnsubscribe"; + +function webFrame(requestId: string, frameCase: WebFrameCase, value: unknown): WebClientFrame { + return create(WebClientFrameSchema, { + requestId, + // 受控断言换取 switch 简洁:各分支 value 均为对应 schema 实例,oneof 判别类型无法自动收窄。 + payload: { case: frameCase, value } as WebClientFrame["payload"], + }); +} + +function buildChatCommand(body: J) { + const inner = rec(body.payload); + const selectedModel = rec(inner.selected_model); + const runtimeControls = rec(inner.runtime_controls); + const uploadedFiles = Array.isArray(inner.uploaded_files) ? inner.uploaded_files : []; + return create(ChatCommandRequestSchema, { + type: str(body.type), + request: create(ChatRequestSchema, { + conversationId: str(inner.conversation_id), + message: str(inner.message), + selectedModel: inner.selected_model + ? create(ChatSelectedModelSchema, { + customProviderId: str(selectedModel.custom_provider_id), + model: str(selectedModel.model), + providerType: str(selectedModel.provider_type), + }) + : undefined, + executionMode: str(inner.execution_mode), + workdir: str(inner.workdir), + selectedSystemTools: Array.isArray(inner.selected_system_tools) + ? inner.selected_system_tools.map((item) => str(item)) + : [], + uploadedFiles: uploadedFiles.map((file) => { + const raw = rec(file); + return create(ChatUploadedFileSchema, { + relativePath: str(raw.relative_path), + absolutePath: str(raw.absolute_path), + fileName: str(raw.file_name), + kind: str(raw.kind), + sizeBytes: toI64(raw.size_bytes), + }); + }), + clientRequestId: str(inner.client_request_id), + runtimeControls: inner.runtime_controls + ? create(ChatRuntimeControlsSchema, { + thinkingEnabled: bool(runtimeControls.thinking_enabled), + nativeWebSearchEnabled: bool(runtimeControls.native_web_search_enabled), + reasoning: str(runtimeControls.reasoning), + }) + : undefined, + queuePolicy: str(inner.queue_policy), + }), + baseMessageRef: inner.base_message_ref + ? buildMessageRef(rec(inner.base_message_ref)) + : undefined, + }); +} + +function buildMessageRef(ref: J) { + return create(ChatMessageRefSchema, { + segmentIndex: n32(ref.segment_index), + messageIndex: n32(ref.message_index), + segmentId: str(ref.segment_id), + messageId: str(ref.message_id), + role: str(ref.role), + contentHash: str(ref.content_hash), + }); +} + +// 构造直通 GatewayEnvelope,各臂字段映射与 v1 处理器的 JSON→proto 组包逐一对应。 +function buildAgentRequest(type: string, body: J): GatewayEnvelope { + return create(GatewayEnvelopeSchema, { payload: agentRequestPayload(type, body) }); +} + +function agentRequestPayload(type: string, body: J): GatewayEnvelope["payload"] { + if (type.startsWith("git.")) { + return { + case: "gitRequest", + value: create(GitRequestSchema, { + action: type.slice("git.".length), + workdir: trimStr(body.workdir), + argsJson: body.args === undefined ? "{}" : JSON.stringify(body.args), + }), + }; + } + if (type.startsWith("terminal.")) { + return { + case: "terminalRequest", + value: create(TerminalRequestSchema, { + action: type.slice("terminal.".length), + sessionId: trimStr(body.session_id), + projectPathKey: trimStr(body.project_path_key), + cwd: trimStr(body.cwd), + shell: trimStr(body.shell), + title: trimStr(body.title), + data: str(body.data), + cols: n32(body.cols), + rows: n32(body.rows), + maxBytes: n32(body.max_bytes), + sshHostId: trimStr(body.ssh_host_id), + promptId: trimStr(body.prompt_id), + promptAnswer: str(body.prompt_answer), + trustHostKey: bool(body.trust_host_key), + sftpEnabled: bool(body.sftp_enabled), + tabId: trimStr(body.tab_id), + tabKind: trimStr(body.tab_kind), + }), + }; + } + if (type.startsWith("sftp.")) { + // v1 侧 side 与 direction 互为回落;proto 只保留 direction。 + const direction = trimStr(body.direction) || trimStr(body.side); + return { + case: "sftpRequest", + value: create(SftpRequestSchema, { + action: type.slice("sftp.".length), + sessionId: trimStr(body.session_id), + projectPathKey: trimStr(body.project_path_key), + workdir: trimStr(body.workdir), + localPath: str(body.local_path), + remotePath: str(body.remote_path), + fromPath: str(body.from_path), + toPath: str(body.to_path), + direction, + targetPath: str(body.target_path), + recursive: bool(body.recursive), + overwrite: bool(body.overwrite), + }), + }; + } + if (type.startsWith("tunnel.")) { + return { + case: "tunnelMutation", + value: create(TunnelMutationSchema, { + action: type.slice("tunnel.".length), + tunnelId: str(body.tunnel_id), + targetUrl: str(body.target_url), + name: str(body.name), + ttlSeconds: typeof body.ttl_seconds === "number" ? n32(body.ttl_seconds) : undefined, + projectPathKey: str(body.project_path_key), + }), + }; + } + if (type.startsWith("process.")) { + return { + case: "managedProcessRequest", + value: create(ManagedProcessRequestSchema, { + action: type.slice("process.".length), + processId: trimStr(body.process_id), + maxBytes: n32(body.max_bytes), + }), + }; + } + if (type.startsWith("chat_queue.")) { + return { + case: "chatQueue", + value: create(ChatQueueRequestSchema, { + action: type.slice("chat_queue.".length), + conversationId: trimStr(body.conversation_id), + itemId: trimStr(body.item_id), + direction: trimStr(body.direction), + revision: toI64(body.revision), + draftJson: trimStr(body.draft_json), + uploadedFilesJson: trimStr(body.uploaded_files_json), + requestJson: trimStr(body.request_json), + }), + }; + } + + switch (type) { + case "history.list": + return { + case: "historyList", + value: create(HistoryListRequestSchema, { + page: n32(body.page), + pageSize: n32(body.page_size), + cwd: trimStr(body.cwd), + cwdEmpty: bool(body.cwd_empty), + }), + }; + case "history.workdirs": + return { case: "historyWorkdirs", value: create(HistoryWorkdirsRequestSchema, {}) }; + case "history.shared_list": + // v1 网关把 shared_list 转译为 memory_manage 直通命令;v2 客户端直接构造同一命令,结果形状不变。 + return { + case: "memoryManage", + value: create(MemoryManageRequestSchema, { + command: "history_shared_list", + argsJson: JSON.stringify({ page: n32(body.page), page_size: n32(body.page_size) }), + }), + }; + case "history.get": + return { + case: "historyGet", + value: create(HistoryGetRequestSchema, { + conversationId: trimStr(body.conversation_id), + maxMessages: n32(body.max_messages), + }), + }; + case "history.prefix": + return { + case: "historyPrefix", + value: create(HistoryPrefixRequestSchema, { + conversationId: trimStr(body.conversation_id), + maxMessages: n32(body.max_messages), + baseMessageRef: body.base_message_ref + ? buildMessageRef(rec(body.base_message_ref)) + : undefined, + }), + }; + case "history.rename": + return { + case: "historyRename", + value: create(HistoryRenameRequestSchema, { + conversationId: trimStr(body.conversation_id), + title: trimStr(body.title), + }), + }; + case "history.branch": + return { + case: "historyBranch", + value: create(HistoryBranchRequestSchema, { + conversationId: trimStr(body.conversation_id), + baseMessageRef: body.base_message_ref + ? buildMessageRef(rec(body.base_message_ref)) + : undefined, + }), + }; + case "history.pin": + return { + case: "historyPin", + value: create(HistoryPinRequestSchema, { + conversationId: trimStr(body.conversation_id), + isPinned: bool(body.is_pinned), + }), + }; + case "history.share.get": + return { + case: "historyShareGet", + value: create(HistoryShareGetRequestSchema, { + conversationId: trimStr(body.conversation_id), + }), + }; + case "history.share.set": + return { + case: "historyShareSet", + value: create(HistoryShareSetRequestSchema, { + conversationId: trimStr(body.conversation_id), + enabled: bool(body.enabled), + redactToolContent: optBool(body.redact_tool_content), + }), + }; + case "history.delete": + return { + case: "historyDelete", + value: create(HistoryDeleteRequestSchema, { + conversationId: trimStr(body.conversation_id), + }), + }; + case "providers.list": + return { case: "providerList", value: create(ProviderListRequestSchema, {}) }; + case "provider.models": + return { + case: "providerModels", + value: create(ProviderModelsRequestSchema, { + providerType: trimStr(body.type), + baseUrl: trimStr(body.base_url), + apiKey: trimStr(body.api_key), + useSystemProxy: bool(body.use_system_proxy), + }), + }; + case "settings.get": + return { case: "settingsGet", value: create(SettingsGetRequestSchema, {}) }; + case "settings.update": + return { + case: "settingsUpdate", + value: create(SettingsUpdateRequestSchema, { settingsJson: JSON.stringify(rec(body)) }), + }; + case "settings.ssh_known_host.reset": + return { + case: "settingsResetSshKnownHost", + value: create(SettingsResetSshKnownHostRequestSchema, { + host: trimStr(body.host), + port: n32(body.port), + }), + }; + case "skills.list": + return { case: "skillFilesList", value: create(SkillFilesListRequestSchema, {}) }; + case "skills.manage": + return { + case: "skillManage", + value: create(SkillManageRequestSchema, { payloadJson: JSON.stringify(rec(body)) }), + }; + case "skills.read-metadata": + return { + case: "skillMetadataRead", + value: create(SkillMetadataReadRequestSchema, { path: trimStr(body.path) }), + }; + case "skills.read-text": + return { + case: "skillTextRead", + value: create(SkillTextReadRequestSchema, { + path: trimStr(body.path), + offset: n32(body.offset), + length: n32(body.length), + }), + }; + case "mentions.list": + return { + case: "fileMentionList", + value: create(FileMentionListRequestSchema, { + workdir: trimStr(body.workdir), + maxResults: n32(body.max_results), + query: trimStr(body.query), + showHidden: optBool(body.show_hidden), + }), + }; + case "files.preview": + return { + case: "uploadedImagePreview", + value: create(UploadedImagePreviewRequestSchema, { + workdir: trimStr(body.workdir), + absolutePath: trimStr(body.absolute_path), + }), + }; + case "memory.manage": + return { + case: "memoryManage", + value: create(MemoryManageRequestSchema, { + command: trimStr(body.command), + argsJson: body.args === undefined ? "{}" : JSON.stringify(body.args), + }), + }; + case "cron.manage": + return { + case: "cronManage", + value: create(CronManageRequestSchema, { + action: str(body.action), + taskId: str(body.task_id), + taskJson: str(body.task_json), + }), + }; + case "fs.roots": + return { case: "fsRoots", value: create(FsRootsRequestSchema, {}) }; + case "fs.list_dirs": + return { + case: "fsListDirs", + value: create(FsListDirsRequestSchema, { + path: trimStr(body.path), + maxResults: n32(body.max_results), + }), + }; + case "fs.create_project_folder": + return { + case: "fsCreateProjectFolder", + value: create(FsCreateProjectFolderRequestSchema, { + parent: trimStr(body.parent), + name: trimStr(body.name), + }), + }; + case "fs.list": + return { + case: "fsList", + value: create(FsListRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + depth: n32(body.depth), + offset: n32(body.offset), + maxResults: n32(body.max_results), + showHidden: optBool(body.show_hidden), + }), + }; + case "fs.write_text": + return { + case: "fsWriteText", + value: create(FsWriteTextRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + content: str(body.content), + mode: trimStr(body.mode) || "rewrite", + expectedMtimeMs: toI64(body.expected_mtime_ms), + expectedContentHash: trimStr(body.expected_content_hash), + hasExpectedMtimeMs: body.expected_mtime_ms !== undefined, + hasExpectedContentHash: body.expected_content_hash !== undefined, + }), + }; + case "fs.create_dir": + return { + case: "fsCreateDir", + value: create(FsCreateDirRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + }), + }; + case "fs.rename": + return { + case: "fsRename", + value: create(FsRenameRequestSchema, { + workdir: trimStr(body.workdir), + fromPath: trimStr(body.from_path), + toPath: trimStr(body.to_path), + }), + }; + case "fs.delete": + return { + case: "fsDelete", + value: create(FsDeleteRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + }), + }; + case "fs.read_editable_text": + return { + case: "fsReadEditableText", + value: create(FsReadEditableTextRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + }), + }; + case "fs.read_workspace_image": + return { + case: "fsReadWorkspaceImage", + value: create(FsReadWorkspaceImageRequestSchema, { + workdir: trimStr(body.workdir), + path: trimStr(body.path), + }), + }; + default: + throw new Error(`unsupported gateway request type: ${type}`); + } +} + +// --------------------------------------------------------------------------- +// 入站:服务端帧解码 +// --------------------------------------------------------------------------- + +export type DecodedServerFrame = + | { + kind: "hello"; + requestId: string; + ok: boolean; + message: string; + heartbeatPeriodSeconds: number; + maxMessageBytes: number; + serverTime: number; + } + | { kind: "ping"; timestamp: number } + | { kind: "response"; requestId: string; payload: unknown } + | { kind: "error"; requestId: string; message: string } + | { kind: "event"; type: string; payload: unknown }; + +export function decodeServerFrameBinary(data: ArrayBuffer | Uint8Array): WebServerFrame { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + return fromBinary(WebServerFrameSchema, bytes); +} + +// 把一帧 WebServerFrame 归一化为分发单元。agentOnline 用于 process.state:v1 由网关注入 +// agent_online,v2 改由客户端从 status 事件维护的在线位补齐。 +export function decodeServerFrame( + frame: WebServerFrame, + options: { agentOnline: boolean }, +): DecodedServerFrame | null { + const requestId = frame.requestId ?? ""; + const payload = frame.payload; + switch (payload.case) { + case "hello": + return { + kind: "hello", + requestId, + ok: payload.value.ok, + message: payload.value.message, + heartbeatPeriodSeconds: payload.value.heartbeatPeriodSeconds, + maxMessageBytes: num(payload.value.maxMessageBytes), + serverTime: num(payload.value.serverTime), + }; + case "ping": + return { kind: "ping", timestamp: num(payload.value.timestamp) }; + case "localError": + return { + kind: "error", + requestId, + message: payload.value.message || "Request failed", + }; + case "agentResponse": + try { + return { + kind: "response", + requestId, + payload: decodeAgentResponse(payload.value, options), + }; + } catch (error) { + return { + kind: "error", + requestId, + message: error instanceof Error ? error.message : "Request failed", + }; + } + case "status": + // status 臂身兼二职:带 request_id 是 status.get/chat.prepare 响应,空则为广播(v1 "status.event")。 + return requestId + ? { kind: "response", requestId, payload: statusPayload(payload.value) } + : { kind: "event", type: "status.event", payload: statusPayload(payload.value) }; + case "chatSubscribed": + return { kind: "response", requestId, payload: chatSubscribedPayload(payload.value) }; + case "chatAccepted": + return { + kind: "response", + requestId, + payload: { + run_id: payload.value.runId, + conversation_id: payload.value.conversationId, + accepted_seq: num(payload.value.acceptedSeq), + deduped: payload.value.deduped, + }, + }; + case "chatActivities": + return { + kind: "response", + requestId, + payload: { + running_conversations: payload.value.runningConversations.map(runningConversationPayload), + }, + }; + case "chatCancelled": + return { + kind: "response", + requestId, + payload: { + ok: payload.value.ok, + run_id: payload.value.runId, + conversation_id: payload.value.conversationId, + }, + }; + case "ack": + return { kind: "response", requestId, payload: { ok: payload.value.ok } }; + case "chatEvent": { + const parsed = parseJsonBytes(payload.value.payloadJson); + return parsed === undefined ? null : { kind: "event", type: "chat.event", payload: parsed }; + } + case "chatCommandUpdate": + return { + kind: "event", + type: "chat.command_update", + payload: chatCommandUpdatePayload(payload.value), + }; + case "chatSubscriptionReset": + return { + kind: "event", + type: "chat.subscription_reset", + payload: { conversation_id: payload.value.conversationId }, + }; + case "chatActivity": + return { kind: "event", type: "chat.activity", payload: chatActivityPayload(payload.value) }; + case "historyEvent": + return { kind: "event", type: "history.event", payload: historyEventPayload(payload.value) }; + case "settingsEvent": { + // v1 网关把 settings_json 解析成对象后转发;v2 移到客户端解析。 + const parsed = settingsEventPayload(payload.value); + return parsed === null ? null : { kind: "event", type: "settings.event", payload: parsed }; + } + case "terminalEvent": + return { + kind: "event", + type: "terminal.event", + payload: terminalEventPayload(payload.value), + }; + case "sftpEvent": + return { kind: "event", type: "sftp.event", payload: sftpEventPayload(payload.value) }; + case "chatQueueEvent": + return { + kind: "event", + type: "chat_queue.event", + payload: chatQueueEventPayload(payload.value), + }; + case "tunnelState": + return { kind: "event", type: "tunnel.state", payload: tunnelStatePayload(payload.value) }; + case "processState": + return { + kind: "event", + type: "process.state", + payload: processStatePayload(payload.value, options.agentOnline), + }; + case "workspaceActivity": + return { + kind: "event", + type: "workspace.activity", + payload: workspaceActivityPayload(payload.value), + }; + default: + return null; + } +} + +function parseJsonBytes(bytes: Uint8Array): unknown { + try { + return parseJson(textDecoder.decode(bytes)); + } catch { + return undefined; + } +} + +// --------------------------------------------------------------------------- +// 直通响应(AgentEnvelope)→ v1 JSON 线格式 +// --------------------------------------------------------------------------- + +function decodeAgentResponse(envelope: AgentEnvelope, options: { agentOnline: boolean }): unknown { + const payload = envelope.payload; + switch (payload.case) { + case "error": + frameError(payload.value.message || "Request failed"); + break; + case "historyListResp": + // running_conversations 由 gatewaySocket 侧经 chat.activities 帧合并(v1 由网关顺带附上)。 + return { + conversations: payload.value.conversations.map(conversationSummaryPayload), + total_count: payload.value.totalCount, + }; + case "historyGetResp": + case "historyPrefixResp": + return { + conversation_id: payload.value.conversationId, + messages_json: payload.value.messagesJson, + total_message_count: payload.value.totalMessageCount, + returned_message_count: payload.value.returnedMessageCount, + has_more: payload.value.hasMore, + conversation: payload.value.conversation + ? conversationSummaryPayload(payload.value.conversation) + : null, + }; + case "historyRenameResp": + case "historyBranchResp": + case "historyPinResp": + if (!payload.value.conversation) { + frameError("unexpected agent response"); + } + return conversationSummaryPayload(payload.value.conversation); + case "historyShareGetResp": + case "historyShareSetResp": + if (!payload.value.share) { + frameError("unexpected agent response"); + } + return historyShareStatusPayload(payload.value.share); + case "historyDeleteResp": + return { ok: true }; + case "historyWorkdirsResp": + return { + workdirs: payload.value.workdirs.map((workdir) => ({ + path: workdir.path, + conversation_count: workdir.conversationCount, + updated_at: num(workdir.updatedAt), + })), + }; + case "providerListResp": { + const raw = payload.value.providersJson.trim(); + if (!raw) return []; + try { + return parseJson(raw); + } catch { + frameError("provider list response is not valid JSON"); + } + break; + } + case "providerModelsResp": + try { + return parseJson(payload.value.modelsJson); + } catch { + frameError("provider model response is not valid JSON"); + } + break; + case "settingsGetResp": { + const raw = payload.value.settingsJson.trim(); + if (!raw) return {}; + try { + const parsed = parseJson(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + frameError("gateway settings payload is not valid JSON"); + } + break; + } + case "settingsUpdateResp": + return { accepted: payload.value.accepted, message: payload.value.message.trim() }; + case "settingsResetSshKnownHostResp": + return { deleted: payload.value.deleted }; + case "skillFilesListResp": + return { + rootDir: payload.value.rootDir, + paths: payload.value.paths, + truncated: payload.value.truncated, + }; + case "skillMetadataReadResp": { + const name = payload.value.name.trim(); + const description = payload.value.description.trim(); + return { name: name || null, description: description || null }; + } + case "skillTextReadResp": + return { content: payload.value.content, truncated: payload.value.truncated }; + case "skillManageResp": { + const raw = payload.value.resultJson.trim(); + if (!raw) return {}; + try { + return parseJson(raw); + } catch { + frameError("skill manage response is not valid JSON"); + } + break; + } + case "fileMentionListResp": + return { + entries: payload.value.entries.map((entry) => ({ + path: entry.path, + kind: entry.kind, + hidden: entry.hidden, + })), + truncated: payload.value.truncated, + }; + case "uploadedImagePreviewResp": + return { mimeType: payload.value.mimeType, data: payload.value.data }; + case "memoryManageResp": + return unmarshalJsonPayload(payload.value.resultJson); + case "cronManageResp": + return { action: payload.value.action, result_json: payload.value.resultJson }; + case "fsRootsResp": + return { + roots: payload.value.roots.map((root) => ({ + id: root.id, + path: root.path, + kind: root.kind, + label: root.label, + })), + }; + case "fsListDirsResp": + return { + path: payload.value.path.trim(), + entries: payload.value.entries.map((entry) => ({ path: entry.path, name: entry.name })), + truncated: payload.value.truncated, + }; + case "fsCreateProjectFolderResp": + return { path: payload.value.path.trim() }; + case "fsListResp": + return { + path: payload.value.hasPath ? payload.value.path : null, + depth: payload.value.depth, + offset: payload.value.offset, + maxResults: payload.value.maxResults, + total: payload.value.total, + hasMore: payload.value.hasMore, + entries: payload.value.entries.map((entry) => ({ + path: entry.path, + kind: entry.kind, + hidden: entry.hidden, + })), + }; + case "fsReadEditableTextResp": + return { + path: payload.value.path, + content: payload.value.content, + mtimeMs: num(payload.value.mtimeMs), + contentHash: payload.value.contentHash, + sizeBytes: num(payload.value.sizeBytes), + totalLines: num(payload.value.totalLines), + }; + case "fsReadWorkspaceImageResp": + return { + path: payload.value.path, + mimeType: payload.value.mimeType, + data: payload.value.data, + sizeBytes: num(payload.value.sizeBytes), + mtimeMs: num(payload.value.mtimeMs), + contentHash: payload.value.contentHash, + }; + case "fsWriteTextResp": + return { + path: payload.value.path, + mode: payload.value.mode, + existedBefore: payload.value.existedBefore, + bytesWritten: num(payload.value.bytesWritten), + mtimeMs: num(payload.value.mtimeMs), + contentHash: payload.value.contentHash, + totalLines: num(payload.value.totalLines), + }; + case "fsCreateDirResp": + return { path: payload.value.path, kind: payload.value.kind }; + case "fsRenameResp": + return { + fromPath: payload.value.fromPath, + path: payload.value.path, + kind: payload.value.kind, + }; + case "fsDeleteResp": + return { path: payload.value.path, kind: payload.value.kind }; + case "gitResponse": + return unmarshalJsonPayload(payload.value.resultJson); + case "sftpResponse": + return sftpResponsePayload(payload.value); + case "chatQueueResp": + return { + accepted: payload.value.accepted, + message: payload.value.message, + snapshot_json: payload.value.snapshotJson, + item_json: payload.value.itemJson, + error_code: payload.value.errorCode, + revision: num(payload.value.revision), + }; + case "terminalResponse": + return terminalResponsePayload(payload.value); + case "tunnelMutationResult": + if (payload.value.errorMessage) { + frameError(payload.value.errorMessage); + } + return { tunnel_id: payload.value.tunnelId }; + case "managedProcessResponse": + return managedProcessResponsePayload(payload.value, options.agentOnline); + default: + frameError("unexpected agent response"); + } +} + +function unmarshalJsonPayload(raw: string): unknown { + const trimmed = raw.trim(); + if (!trimmed) return {}; + try { + const parsed = parseJson(trimmed); + return parsed ?? {}; + } catch { + frameError("response is not valid JSON"); + } +} + +// --------------------------------------------------------------------------- +// v1 载荷塑形(与 Go 手工 map 一一对应) +// --------------------------------------------------------------------------- + +// 对应 websocketConversationSummaryPayload:protojson UseProtoNames+EmitUnpopulated(全字段恒出现), +// 64 位数字转 number。 +function conversationSummaryPayload(conversation: ConversationSummary): J { + return { + id: conversation.id, + title: conversation.title, + created_at: num(conversation.createdAt), + updated_at: num(conversation.updatedAt), + message_count: conversation.messageCount, + provider_id: conversation.providerId, + model: conversation.model, + session_id: conversation.sessionId, + cwd: conversation.cwd, + is_pinned: conversation.isPinned, + pinned_at: num(conversation.pinnedAt), + is_shared: conversation.isShared, + selected_model_json: conversation.selectedModelJson, + }; +} + +function historyShareStatusPayload(share: HistoryShareStatus): J { + return { + conversation_id: share.conversationId, + enabled: share.enabled, + token: share.token, + created_at: num(share.createdAt), + updated_at: num(share.updatedAt), + redact_tool_content: share.redactToolContent, + }; +} + +function historyEventPayload(event: HistorySyncEvent): J { + const payload: J = { + kind: event.kind.trim(), + conversation_id: event.conversationId.trim(), + }; + if (event.conversation) { + payload.conversation = conversationSummaryPayload(event.conversation); + } + return payload; +} + +function settingsEventPayload(event: SettingsSyncEvent): J | null { + const raw = event.settingsJson.trim(); + if (!raw) return {}; + try { + const parsed = parseJson(raw); + return parsed && typeof parsed === "object" ? (parsed as J) : {}; + } catch { + return null; + } +} + +function chatQueueEventPayload(event: ChatQueueEvent): J { + return { + conversation_id: event.conversationId.trim(), + snapshot_json: event.snapshotJson.trim(), + revision: num(event.revision), + }; +} + +function workspaceActivityPayload(event: WorkspaceActivityEvent): J { + return { + workdir: event.workdir, + revision: num(event.revision), + fs: event.fs, + git: event.git, + changedPaths: event.changedPaths, + truncated: event.truncated, + }; +} + +// 对应 session.Status 的 json tag(含 omitempty 语义)。 +function statusPayload(status: StatusEvent): J { + const payload: J = { + online: status.online, + agent_ready: status.agentReady, + chat_runtime_ready: status.chatRuntimeReady, + agent_id: status.agentId, + agent_version: status.agentVersion, + connected_since: num(status.connectedSince), + last_heartbeat: num(status.lastHeartbeat), + }; + if (status.sessionId) payload.session_id = status.sessionId; + if (status.runtimeState) payload.runtime_state = status.runtimeState; + if (num(status.runtimeLastHeartbeat) !== 0) { + payload.runtime_last_heartbeat = num(status.runtimeLastHeartbeat); + } + if (status.runtimeWorkerId) payload.runtime_worker_id = status.runtimeWorkerId; + if (status.runtimeVisible) payload.runtime_visible = status.runtimeVisible; + if (status.runtimeActiveRunCount) { + payload.runtime_active_run_count = status.runtimeActiveRunCount; + } + return payload; +} + +// 对应 websocketRunActivityPayload(updated_at 为 Unix 毫秒)。 +function runActivityPayload(activity: ChatRunActivity | undefined): J | null { + if (!activity || !activity.runId) return null; + const payload: J = { + run_id: activity.runId, + state: activity.state, + started_seq: num(activity.startedSeq), + updated_at: num(activity.updatedAtMs), + }; + if (activity.toolStatus) { + payload.tool_status = activity.toolStatus; + payload.tool_status_is_compaction = activity.toolStatusIsCompaction; + } + if (activity.clientRequestId) { + payload.client_request_id = activity.clientRequestId; + } + return payload; +} + +function runSnapshotPayload(snapshot: ChatRunSnapshot | undefined): J | null { + if (!snapshot || !snapshot.runId) return null; + return { + run_id: snapshot.runId, + revision: num(snapshot.revision), + entries_json: snapshot.entriesJson, + tool_status: snapshot.toolStatus, + tool_status_is_compaction: snapshot.toolStatusIsCompaction, + as_of_seq: num(snapshot.asOfSeq), + }; +} + +function chatSubscribedPayload(result: ChatSubscribeResult): J { + const events: unknown[] = []; + for (const raw of result.eventsJson) { + const parsed = parseJsonBytes(raw); + if (parsed !== undefined) { + events.push(parsed); + } + } + return { + conversation_id: result.conversationId, + stream_epoch: result.streamEpoch, + latest_seq: num(result.latestSeq), + reset: result.reset, + activity: runActivityPayload(result.activity), + snapshot: runSnapshotPayload(result.snapshot), + events, + }; +} + +// 对应 websocketRunningConversationsPayload(workdir 映射为 cwd)。 +function runningConversationPayload(activity: ChatRunActivity): J { + return { + conversation_id: activity.conversationId, + run_id: activity.runId, + state: activity.state, + cwd: activity.workdir, + updated_at: num(activity.updatedAtMs), + }; +} + +// 对应 websocketChatActivityPayload(可选键仅在非空时出现)。 +function chatActivityPayload(event: ChatActivityEvent): J { + const payload: J = { + conversation_id: event.conversationId, + running: event.running, + updated_at: num(event.updatedAtMs), + }; + if (event.runId) payload.run_id = event.runId; + if (event.clientRequestId) payload.client_request_id = event.clientRequestId; + if (event.state) payload.state = event.state; + if (event.workdir) payload.workdir = event.workdir; + return payload; +} + +function chatCommandUpdatePayload(update: ChatCommandUpdate): J { + const payload: J = { + run_id: update.runId, + client_request_id: update.clientRequestId, + phase: update.phase, + }; + if (update.conversationId) payload.conversation_id = update.conversationId; + if (update.errorCode) payload.error_code = update.errorCode; + if (update.message) payload.message = update.message; + return payload; +} + +// --------------------------------------------------------------------------- +// 终端 / SFTP / 隧道 / 进程载荷(对应 websocket_payloads.go 等) +// --------------------------------------------------------------------------- + +export function terminalSessionPayload(session: TerminalSession | undefined): J | null { + if (!session) return null; + const kind = session.kind.trim() === "ssh" ? "ssh" : "local"; + const payload: J = { + id: session.id.trim(), + project_path_key: session.projectPathKey.trim(), + cwd: session.cwd.trim(), + shell: session.shell.trim(), + title: session.title.trim(), + kind, + pid: session.pid, + cols: session.cols, + rows: session.rows, + created_at: num(session.createdAt), + updated_at: num(session.updatedAt), + finished_at: num(session.finishedAt), + exit_code: session.exitCode, + running: session.running, + }; + if (session.pid === 0) payload.pid = null; + if (num(session.finishedAt) === 0) payload.finished_at = null; + if (kind === "ssh") payload.pid = null; + if (session.ssh) { + payload.ssh = { + host_id: session.ssh.hostId.trim(), + host_name: session.ssh.hostName.trim(), + username: session.ssh.username.trim(), + host: session.ssh.host.trim(), + port: session.ssh.port, + auth_type: session.ssh.authType.trim(), + status: session.ssh.status.trim(), + reconnect_attempt: session.ssh.reconnectAttempt, + reconnect_max_attempts: session.ssh.reconnectMaxAttempts, + sftp_enabled: session.ssh.sftpEnabled, + sftpEnabled: session.ssh.sftpEnabled, + }; + } + return payload; +} + +function terminalSshTabsPayload(snapshot: TerminalSshTabsSnapshot | undefined): J | null { + if (!snapshot) return null; + return { + project_path_key: snapshot.projectPathKey.trim(), + tabs: snapshot.tabs.map((tab) => ({ + id: tab.id.trim(), + session_id: tab.sessionId.trim(), + project_path_key: tab.projectPathKey.trim(), + kind: tab.kind.trim(), + created_at: num(tab.createdAt), + updated_at: num(tab.updatedAt), + })), + revision: num(snapshot.revision), + }; +} + +function terminalResponsePayload(resp: TerminalResponse): J { + const sessions: J[] = []; + for (const session of resp.sessions) { + const payload = terminalSessionPayload(session); + if (payload) sessions.push(payload); + } + const payload: J = { + action: resp.action.trim(), + sessions, + output: textDecoder.decode(resp.output), + output_bytes: resp.output, + truncated: resp.truncated, + shell_options: resp.shellOptions.map((option) => ({ + id: option.id.trim(), + label: option.label.trim(), + command: option.command.trim(), + })), + default_shell: resp.defaultShell, + }; + if ( + num(resp.outputStartOffset) !== 0 || + num(resp.outputEndOffset) !== 0 || + resp.output.length > 0 + ) { + payload.output_start_offset = num(resp.outputStartOffset); + payload.output_end_offset = num(resp.outputEndOffset); + } + if (resp.latencyMs > 0) payload.latency_ms = resp.latencyMs; + const session = terminalSessionPayload(resp.session); + if (session) payload.session = session; + if (resp.sshPrompt) { + payload.ssh_prompt = { + id: resp.sshPrompt.id.trim(), + kind: resp.sshPrompt.kind.trim(), + host_id: resp.sshPrompt.hostId.trim(), + host_name: resp.sshPrompt.hostName.trim(), + host: resp.sshPrompt.host.trim(), + port: resp.sshPrompt.port, + message: resp.sshPrompt.message.trim(), + fingerprint_sha256: resp.sshPrompt.fingerprintSha256.trim(), + key_type: resp.sshPrompt.keyType.trim(), + answer_echo: resp.sshPrompt.answerEcho, + }; + } + const sshTabs = terminalSshTabsPayload(resp.sshTabs); + if (sshTabs) payload.ssh_tabs = sshTabs; + return payload; +} + +function terminalEventPayload(event: TerminalEvent): J { + const payload: J = { + kind: event.kind.trim(), + session_id: event.sessionId.trim(), + project_path_key: event.projectPathKey.trim(), + }; + if (event.data.length > 0) { + payload.data = textDecoder.decode(event.data); + payload.data_bytes = event.data; + } + if ( + num(event.outputStartOffset) !== 0 || + num(event.outputEndOffset) !== 0 || + event.data.length > 0 + ) { + payload.output_start_offset = num(event.outputStartOffset); + payload.output_end_offset = num(event.outputEndOffset); + } + const session = terminalSessionPayload(event.session); + if (session) payload.session = session; + const sshTabs = terminalSshTabsPayload(event.sshTabs); + if (sshTabs) payload.ssh_tabs = sshTabs; + return payload; +} + +function sftpEntryPayload(entry: SftpEntry): J { + return { + path: entry.path, + name: entry.name, + kind: entry.kind.trim(), + sizeBytes: num(entry.sizeBytes), + size_bytes: num(entry.sizeBytes), + mtime: num(entry.mtime), + }; +} + +function sftpTransferPayload(transfer: SftpTransfer): J { + return { + id: transfer.id.trim(), + sessionId: transfer.sessionId.trim(), + session_id: transfer.sessionId.trim(), + direction: transfer.direction.trim(), + status: transfer.status.trim(), + sourcePath: transfer.sourcePath, + source_path: transfer.sourcePath, + targetPath: transfer.targetPath, + target_path: transfer.targetPath, + currentPath: transfer.currentPath, + current_path: transfer.currentPath, + bytesDone: num(transfer.bytesDone), + bytes_done: num(transfer.bytesDone), + bytesTotal: num(transfer.bytesTotal), + bytes_total: num(transfer.bytesTotal), + filesDone: transfer.filesDone, + files_done: transfer.filesDone, + filesTotal: transfer.filesTotal, + files_total: transfer.filesTotal, + error: transfer.error.trim(), + }; +} + +function sftpResponsePayload(resp: SftpResponse): J { + const payload: J = { + action: resp.action.trim(), + path: resp.path, + exists: resp.exists, + entries: resp.entries.map(sftpEntryPayload), + }; + if (resp.entry) payload.entry = sftpEntryPayload(resp.entry); + if (resp.transfer) payload.transfer = sftpTransferPayload(resp.transfer); + return payload; +} + +function sftpEventPayload(event: SftpEvent): J { + const payload: J = { kind: event.kind.trim() }; + if (event.transfer) payload.transfer = sftpTransferPayload(event.transfer); + return payload; +} + +function tunnelHealthPayload(health: TunnelHealth | undefined): J | null { + if (!health) return null; + return { + status: health.status, + http_status: health.httpStatus, + error: health.error, + checked_at: num(health.checkedAt), + rtt_ms: health.rttMs, + }; +} + +function tunnelStatePayload(snapshot: TunnelStateSnapshot): J { + return { + revision: num(snapshot.revision), + agent_online: snapshot.agentOnline, + relay: tunnelHealthPayload(snapshot.relay), + tunnels: snapshot.tunnels.map((tunnel) => ({ + id: tunnel.id, + slug: tunnel.slug, + name: tunnel.name, + target_url: tunnel.targetUrl, + public_path: tunnel.publicPath, + created_at: num(tunnel.createdAt), + expires_at: num(tunnel.expiresAt), + active_connections: tunnel.activeConnections, + project_path_key: tunnel.projectPathKey, + local: tunnelHealthPayload(tunnel.local), + })), + }; +} + +// 对应 websocketManagedProcessPayload;agent_online 由客户端 status 在线位注入(v1 由网关注入)。 +function processStatePayload( + snapshot: ManagedProcessSnapshot | undefined, + agentOnline: boolean, +): J { + const processes: J[] = []; + let revision = 0; + if (snapshot) { + revision = num(snapshot.revision); + for (const record of snapshot.processes) { + const entry: J = { + id: record.id, + label: record.label, + command: record.command, + cwd: record.cwd, + shell: record.shell, + pid: record.pid, + log_path: record.logPath, + started_at: num(record.startedAt), + running: record.running, + isolated: record.isolated, + restored: record.restored, + }; + if (record.finishedAt !== undefined) entry.finished_at = num(record.finishedAt); + if (record.exitCode !== undefined) entry.exit_code = record.exitCode; + processes.push(entry); + } + } + return { revision, agent_online: agentOnline, processes }; +} + +function managedProcessResponsePayload( + resp: ManagedProcessResponse, + agentOnline: boolean, +): unknown { + const action = resp.action.trim(); + switch (action) { + case "snapshot": + // v1 的 process.snapshot 直接返回扁平状态载荷。 + return processStatePayload(resp.snapshot, agentOnline); + case "stop": + return { + action, + stopped: resp.stopped, + state: processStatePayload(resp.snapshot, agentOnline), + }; + case "clear": + return { action, state: processStatePayload(resp.snapshot, agentOnline) }; + case "read_log": + return { + action, + log_content: resp.logContent, + log_path: resp.logPath, + log_truncated: resp.logTruncated, + }; + default: + return { action }; + } +} + +// --------------------------------------------------------------------------- +// 终端数据面(/ws/v2/terminal)帧编解码 +// --------------------------------------------------------------------------- + +// TerminalWireHeader 保持旧自定义帧头字段命名,上层 attach/snapshot/output/error 路由无需感知 proto 化。 +export type TerminalWireHeader = { + kind?: string; + streamId?: string; + sessionId?: string; + projectPathKey?: string; + seq?: number; + startOffset?: number; + endOffset?: number; + cols?: number; + rows?: number; + maxBytes?: number; + truncated?: boolean; + error?: string; + session?: unknown; +}; + +export function encodeTerminalHelloFrame(token: string): WireBytes { + const frame = create(TerminalClientFrameSchema, { + payload: { + case: "hello", + value: create(ClientHelloSchema, { + protocolVersion: GATEWAY_V2_PROTOCOL_VERSION, + role: ClientRole.BROWSER, + token, + clientName: "webui", + clientVersion: "", + }), + }, + }); + return wireBytes(toBinary(TerminalClientFrameSchema, frame)); +} + +export function encodeTerminalStreamFrame( + header: TerminalWireHeader, + data: Uint8Array = new Uint8Array(), +): WireBytes { + const frame = create(TerminalClientFrameSchema, { + payload: { + case: "frame", + value: create(TerminalStreamFrameSchema, { + kind: header.kind?.trim() ?? "", + streamId: header.streamId ?? "", + sessionId: header.sessionId ?? "", + projectPathKey: header.projectPathKey ?? "", + seq: toI64(header.seq), + startOffset: toI64(header.startOffset), + endOffset: toI64(header.endOffset), + cols: n32(header.cols), + rows: n32(header.rows), + maxBytes: n32(header.maxBytes), + truncated: header.truncated === true, + error: header.error ?? "", + data, + }), + }, + }); + return wireBytes(toBinary(TerminalClientFrameSchema, frame)); +} + +export type DecodedTerminalServerFrame = + | { kind: "hello"; ok: boolean; message: string } + | { kind: "frame"; header: TerminalWireHeader; data: Uint8Array }; + +export function decodeTerminalServerFrame( + data: ArrayBuffer | Uint8Array, +): DecodedTerminalServerFrame | null { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + let frame: TerminalServerFrame; + try { + frame = fromBinary(TerminalServerFrameSchema, bytes); + } catch { + return null; + } + const payload = frame.payload; + if (payload.case === "hello") { + return { kind: "hello", ok: payload.value.ok, message: payload.value.message }; + } + if (payload.case === "frame") { + return { kind: "frame", header: terminalStreamHeader(payload.value), data: payload.value.data }; + } + return null; +} + +function terminalStreamHeader(frame: TerminalStreamFrame): TerminalWireHeader { + return { + kind: frame.kind, + streamId: frame.streamId, + sessionId: frame.sessionId, + projectPathKey: frame.projectPathKey, + seq: num(frame.seq), + startOffset: num(frame.startOffset), + endOffset: num(frame.endOffset), + cols: frame.cols, + rows: frame.rows, + maxBytes: frame.maxBytes, + truncated: frame.truncated, + error: frame.error, + session: terminalSessionPayload(frame.session) ?? undefined, + }; +} diff --git a/crates/agent-gateway/web/src/lib/proto/gen/proto/v1/gateway_pb.ts b/crates/agent-gateway/web/src/lib/proto/gen/proto/v1/gateway_pb.ts new file mode 100644 index 000000000..f7a143ebe --- /dev/null +++ b/crates/agent-gateway/web/src/lib/proto/gen/proto/v1/gateway_pb.ts @@ -0,0 +1,5211 @@ +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated from file proto/v1/gateway.proto (package liveagent.gateway.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file proto/v1/gateway.proto. + */ +export const file_proto_v1_gateway: GenFile = /*@__PURE__*/ + fileDesc("ChZwcm90by92MS9nYXRld2F5LnByb3RvEhRsaXZlYWdlbnQuZ2F0ZXdheS52MSJFCgtBdXRoUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhZ2VudF9pZBgCIAEoCRIVCg1hZ2VudF92ZXJzaW9uGAMgASgJIkQKDEF1dGhSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg8KB21lc3NhZ2UYAiABKAkSEgoKc2Vzc2lvbl9pZBgDIAEoCSLpGQoPR2F0ZXdheUVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEkAKDGNoYXRfY29tbWFuZBgKIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRDb21tYW5kUmVxdWVzdEgAEj4KC2Nyb25fbWFuYWdlGBQgASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjEuQ3Jvbk1hbmFnZVJlcXVlc3RIABJACgxoaXN0b3J5X2xpc3QYHiABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5TGlzdFJlcXVlc3RIABI+CgtoaXN0b3J5X2dldBgfIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlHZXRSZXF1ZXN0SAASRAoOaGlzdG9yeV9yZW5hbWUYICABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UmVuYW1lUmVxdWVzdEgAEkQKDmhpc3RvcnlfZGVsZXRlGCEgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeURlbGV0ZVJlcXVlc3RIABJECg5oaXN0b3J5X3ByZWZpeBgiIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlQcmVmaXhSZXF1ZXN0SAASPgoLaGlzdG9yeV9waW4YIyABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UGluUmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfZ2V0GCQgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVNoYXJlR2V0UmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfc2V0GCUgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVNoYXJlU2V0UmVxdWVzdEgAElEKFWhpc3Rvcnlfc2hhcmVfcmVzb2x2ZRgmIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlTaGFyZVJlc29sdmVSZXF1ZXN0SAASSAoQaGlzdG9yeV93b3JrZGlycxgnIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlXb3JrZGlyc1JlcXVlc3RIABJCCg1wcm92aWRlcl9saXN0GCggASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuUHJvdmlkZXJMaXN0UmVxdWVzdEgAEkAKDHNldHRpbmdzX2dldBgpIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzR2V0UmVxdWVzdEgAEkYKD3NldHRpbmdzX3VwZGF0ZRgqIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzVXBkYXRlUmVxdWVzdEgAEkcKEHNraWxsX2ZpbGVzX2xpc3QYKyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5Ta2lsbEZpbGVzTGlzdFJlcXVlc3RIABJNChNza2lsbF9tZXRhZGF0YV9yZWFkGCwgASgLMi4ubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxNZXRhZGF0YVJlYWRSZXF1ZXN0SAASRQoPc2tpbGxfdGV4dF9yZWFkGC0gASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxUZXh0UmVhZFJlcXVlc3RIABJJChFmaWxlX21lbnRpb25fbGlzdBguIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZpbGVNZW50aW9uTGlzdFJlcXVlc3RIABJRChV1cGxvYWRfcmVhZGFibGVfZmlsZXMYLyABKAsyMC5saXZlYWdlbnQuZ2F0ZXdheS52MS5VcGxvYWRSZWFkYWJsZUZpbGVzUmVxdWVzdEgAEjgKCGZzX3Jvb3RzGDAgASgLMiQubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNSb290c1JlcXVlc3RIABI/Cgxmc19saXN0X2RpcnMYMSABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0xpc3REaXJzUmVxdWVzdEgAEjEKBHBpbmcYMiABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52MS5QaW5nUmVxdWVzdEgAElMKFnVwbG9hZGVkX2ltYWdlX3ByZXZpZXcYMyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52MS5VcGxvYWRlZEltYWdlUHJldmlld1JlcXVlc3RIABJCCg1tZW1vcnlfbWFuYWdlGDQgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuTWVtb3J5TWFuYWdlUmVxdWVzdEgAEkAKDHNraWxsX21hbmFnZRg1IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNraWxsTWFuYWdlUmVxdWVzdEgAElYKGGZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlchg2IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzQ3JlYXRlUHJvamVjdEZvbGRlclJlcXVlc3RIABJBChB0ZXJtaW5hbF9yZXF1ZXN0GDcgASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxSZXF1ZXN0SAASNgoHZnNfbGlzdBg4IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzTGlzdFJlcXVlc3RIABJBCg1mc193cml0ZV90ZXh0GDkgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNXcml0ZVRleHRSZXF1ZXN0SAASQQoNZnNfY3JlYXRlX2Rpchg6IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzQ3JlYXRlRGlyUmVxdWVzdEgAEjoKCWZzX3JlbmFtZRg7IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVuYW1lUmVxdWVzdEgAEjoKCWZzX2RlbGV0ZRg8IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzRGVsZXRlUmVxdWVzdEgAEjcKC2dpdF9yZXF1ZXN0GD0gASgLMiAubGl2ZWFnZW50LmdhdGV3YXkudjEuR2l0UmVxdWVzdEgAElAKFWZzX3JlYWRfZWRpdGFibGVfdGV4dBg+IAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZEVkaXRhYmxlVGV4dFJlcXVlc3RIABJUChdmc19yZWFkX3dvcmtzcGFjZV9pbWFnZRg/IAEoCzIxLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVxdWVzdEgAEjkKDHNmdHBfcmVxdWVzdBhAIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNmdHBSZXF1ZXN0SAASRgoPcHJvdmlkZXJfbW9kZWxzGEEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjEuUHJvdmlkZXJNb2RlbHNSZXF1ZXN0SAASXwodc2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3QYSCABKAsyNi5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZXR0aW5nc1Jlc2V0U3NoS25vd25Ib3N0UmVxdWVzdEgAEjwKCmNoYXRfcXVldWUYSSABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0UXVldWVSZXF1ZXN0SAASQQoMdHVubmVsX3N0YXRlGFAgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsU3RhdGVTbmFwc2hvdEgAEj8KD3R1bm5lbF9tdXRhdGlvbhhRIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbE11dGF0aW9uSAASOQoMdHVubmVsX2ZyYW1lGFIgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsRnJhbWVIABJGCg93b3Jrc3BhY2Vfd2F0Y2gYWiABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5Xb3Jrc3BhY2VXYXRjaFJlcXVlc3RIABJOChdtYW5hZ2VkX3Byb2Nlc3NfcmVxdWVzdBhbIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLk1hbmFnZWRQcm9jZXNzUmVxdWVzdEgAEkQKDmhpc3RvcnlfYnJhbmNoGFwgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeUJyYW5jaFJlcXVlc3RIAEIJCgdwYXlsb2FkSgQIQxBESgQIRBBFSgQIRRBGSgQIShBLIqkhCg1BZ2VudEVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEjUKCmNoYXRfZXZlbnQYCiABKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0RXZlbnRIABJEChBjcm9uX21hbmFnZV9yZXNwGBQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuQ3Jvbk1hbmFnZVJlc3BvbnNlSAASRgoRaGlzdG9yeV9saXN0X3Jlc3AYHiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5TGlzdFJlc3BvbnNlSAASRAoQaGlzdG9yeV9nZXRfcmVzcBgfIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlHZXRSZXNwb25zZUgAEkoKE2hpc3RvcnlfcmVuYW1lX3Jlc3AYICABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UmVuYW1lUmVzcG9uc2VIABJKChNoaXN0b3J5X2RlbGV0ZV9yZXNwGCEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeURlbGV0ZVJlc3BvbnNlSAASPgoMaGlzdG9yeV9zeW5jGCIgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVN5bmNFdmVudEgAEkoKE2hpc3RvcnlfcHJlZml4X3Jlc3AYIyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UHJlZml4UmVzcG9uc2VIABJEChBoaXN0b3J5X3Bpbl9yZXNwGCQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVBpblJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9nZXRfcmVzcBglIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlTaGFyZUdldFJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9zZXRfcmVzcBgmIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlTaGFyZVNldFJlc3BvbnNlSAASVwoaaGlzdG9yeV9zaGFyZV9yZXNvbHZlX3Jlc3AYJyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5U2hhcmVSZXNvbHZlUmVzcG9uc2VIABJOChVoaXN0b3J5X3dvcmtkaXJzX3Jlc3AYOCABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5V29ya2RpcnNSZXNwb25zZUgAEkgKEnByb3ZpZGVyX2xpc3RfcmVzcBgoIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYxLlByb3ZpZGVyTGlzdFJlc3BvbnNlSAASRgoRc2V0dGluZ3NfZ2V0X3Jlc3AYKSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZXR0aW5nc0dldFJlc3BvbnNlSAASTAoUc2V0dGluZ3NfdXBkYXRlX3Jlc3AYKiABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZXR0aW5nc1VwZGF0ZVJlc3BvbnNlSAASQAoNc2V0dGluZ3Nfc3luYxgrIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzU3luY0V2ZW50SAASTQoVc2tpbGxfZmlsZXNfbGlzdF9yZXNwGCwgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxGaWxlc0xpc3RSZXNwb25zZUgAElMKGHNraWxsX21ldGFkYXRhX3JlYWRfcmVzcBgtIAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNraWxsTWV0YWRhdGFSZWFkUmVzcG9uc2VIABJLChRza2lsbF90ZXh0X3JlYWRfcmVzcBguIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNraWxsVGV4dFJlYWRSZXNwb25zZUgAEk8KFmZpbGVfbWVudGlvbl9saXN0X3Jlc3AYLyABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52MS5GaWxlTWVudGlvbkxpc3RSZXNwb25zZUgAElcKGnVwbG9hZF9yZWFkYWJsZV9maWxlc19yZXNwGDAgASgLMjEubGl2ZWFnZW50LmdhdGV3YXkudjEuVXBsb2FkUmVhZGFibGVGaWxlc1Jlc3BvbnNlSAASPgoNZnNfcm9vdHNfcmVzcBgxIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUm9vdHNSZXNwb25zZUgAEjIKBHBvbmcYMiABKAsyIi5saXZlYWdlbnQuZ2F0ZXdheS52MS5Qb25nUmVzcG9uc2VIABJFChFmc19saXN0X2RpcnNfcmVzcBgzIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzTGlzdERpcnNSZXNwb25zZUgAElkKG3VwbG9hZGVkX2ltYWdlX3ByZXZpZXdfcmVzcBg0IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYxLlVwbG9hZGVkSW1hZ2VQcmV2aWV3UmVzcG9uc2VIABJIChJtZW1vcnlfbWFuYWdlX3Jlc3AYNSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5NZW1vcnlNYW5hZ2VSZXNwb25zZUgAEkYKEXNraWxsX21hbmFnZV9yZXNwGDYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxNYW5hZ2VSZXNwb25zZUgAElwKHWZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlcl9yZXNwGDcgASgLMjMubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNDcmVhdGVQcm9qZWN0Rm9sZGVyUmVzcG9uc2VIABJDChF0ZXJtaW5hbF9yZXNwb25zZRg5IAEoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsUmVzcG9uc2VIABI9Cg50ZXJtaW5hbF9ldmVudBg6IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsRXZlbnRIABI8Cgxmc19saXN0X3Jlc3AYOyABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0xpc3RSZXNwb25zZUgAEkcKEmZzX3dyaXRlX3RleHRfcmVzcBg8IAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzV3JpdGVUZXh0UmVzcG9uc2VIABJHChJmc19jcmVhdGVfZGlyX3Jlc3AYPSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0NyZWF0ZURpclJlc3BvbnNlSAASQAoOZnNfcmVuYW1lX3Jlc3AYPiABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc1JlbmFtZVJlc3BvbnNlSAASQAoOZnNfZGVsZXRlX3Jlc3AYPyABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0RlbGV0ZVJlc3BvbnNlSAASOQoMZ2l0X3Jlc3BvbnNlGEAgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjEuR2l0UmVzcG9uc2VIABJWChpmc19yZWFkX2VkaXRhYmxlX3RleHRfcmVzcBhBIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZEVkaXRhYmxlVGV4dFJlc3BvbnNlSAASWgocZnNfcmVhZF93b3Jrc3BhY2VfaW1hZ2VfcmVzcBhCIAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVzcG9uc2VIABI7Cg1zZnRwX3Jlc3BvbnNlGEkgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuU2Z0cFJlc3BvbnNlSAASNQoKc2Z0cF9ldmVudBhKIAEoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNmdHBFdmVudEgAEkIKD2NoYXRfcXVldWVfcmVzcBhLIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRRdWV1ZVJlc3BvbnNlSAASQAoQY2hhdF9xdWV1ZV9ldmVudBhMIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRRdWV1ZUV2ZW50SAASPgoMY2hhdF9jb250cm9sGEYgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdENvbnRyb2xFdmVudEgAEkIKDnJ1bnRpbWVfc3RhdHVzGEcgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuUnVudGltZVN0YXR1c0V2ZW50SAASZQoic2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3RfcmVzcBhIIAEoCzI3LmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzUmVzZXRTc2hLbm93bkhvc3RSZXNwb25zZUgAEkoKFWNoYXRfcnVudGltZV9zbmFwc2hvdBhNIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRSdW50aW1lU25hcHNob3RIABJMChRwcm92aWRlcl9tb2RlbHNfcmVzcBhPIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLlByb3ZpZGVyTW9kZWxzUmVzcG9uc2VIABJCCg50dW5uZWxfZGVzaXJlZBhQIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbERlc2lyZWRTdGF0ZUgAEkwKFnR1bm5lbF9tdXRhdGlvbl9yZXN1bHQYUSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5UdW5uZWxNdXRhdGlvblJlc3VsdEgAEjkKDHR1bm5lbF9mcmFtZRhSIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEZyYW1lSAASRgoTdHVubmVsX3Byb2JlX3JlcG9ydBhTIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbFByb2JlUmVwb3J0SAASSgoSd29ya3NwYWNlX2FjdGl2aXR5GFogASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuV29ya3NwYWNlQWN0aXZpdHlFdmVudEgAElAKGG1hbmFnZWRfcHJvY2Vzc19yZXNwb25zZRhbIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLk1hbmFnZWRQcm9jZXNzUmVzcG9uc2VIABJQChhtYW5hZ2VkX3Byb2Nlc3Nfc25hcHNob3QYXCABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52MS5NYW5hZ2VkUHJvY2Vzc1NuYXBzaG90SAASSgoTaGlzdG9yeV9icmFuY2hfcmVzcBhdIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlCcmFuY2hSZXNwb25zZUgAEjQKBWVycm9yGGMgASgLMiMubGl2ZWFnZW50LmdhdGV3YXkudjEuRXJyb3JSZXNwb25zZUgAQgkKB3BheWxvYWRKBAhDEERKBAhEEEVKBAhFEEZKBAhOEE8iVQoRQ2hhdFNlbGVjdGVkTW9kZWwSGgoSY3VzdG9tX3Byb3ZpZGVyX2lkGAEgASgJEg0KBW1vZGVsGAIgASgJEhUKDXByb3ZpZGVyX3R5cGUYAyABKAkiZQoTQ2hhdFJ1bnRpbWVDb250cm9scxIYChB0aGlua2luZ19lbmFibGVkGAEgASgIEiEKGW5hdGl2ZV93ZWJfc2VhcmNoX2VuYWJsZWQYAiABKAgSEQoJcmVhc29uaW5nGAMgASgJInUKEENoYXRVcGxvYWRlZEZpbGUSFQoNcmVsYXRpdmVfcGF0aBgBIAEoCRIVCg1hYnNvbHV0ZV9wYXRoGAIgASgJEhEKCWZpbGVfbmFtZRgDIAEoCRIMCgRraW5kGAQgASgJEhIKCnNpemVfYnl0ZXMYBSABKAMiSwoSVXBsb2FkUmVhZGFibGVGaWxlEhEKCWZpbGVfbmFtZRgBIAEoCRIRCgltaW1lX3R5cGUYAiABKAkSDwoHY29udGVudBgDIAEoDCJmChpVcGxvYWRSZWFkYWJsZUZpbGVzUmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEjcKBWZpbGVzGAIgAygLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuVXBsb2FkUmVhZGFibGVGaWxlImUKG1VwbG9hZFJlYWRhYmxlRmlsZXNSZXNwb25zZRI1CgVmaWxlcxgBIAMoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRVcGxvYWRlZEZpbGUSDwoHc2tpcHBlZBgCIAMoCSJFChtVcGxvYWRlZEltYWdlUHJldmlld1JlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIVCg1hYnNvbHV0ZV9wYXRoGAIgASgJIj8KHFVwbG9hZGVkSW1hZ2VQcmV2aWV3UmVzcG9uc2USEQoJbWltZV90eXBlGAEgASgJEgwKBGRhdGEYAiABKAkiewoKVHVubmVsU3BlYxIKCgJpZBgBIAEoCRIRCglzbHVnX2hpbnQYAiABKAkSDAoEbmFtZRgDIAEoCRISCgp0YXJnZXRfdXJsGAQgASgJEhIKCmV4cGlyZXNfYXQYBSABKAMSGAoQcHJvamVjdF9wYXRoX2tleRgGIAEoCSJZChJUdW5uZWxEZXNpcmVkU3RhdGUSMQoHdHVubmVscxgBIAMoCzIgLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbFNwZWMSEAoIcmV2aXNpb24YAiABKAQiZgoMVHVubmVsSGVhbHRoEg4KBnN0YXR1cxgBIAEoCRITCgtodHRwX3N0YXR1cxgCIAEoDRINCgVlcnJvchgDIAEoCRISCgpjaGVja2VkX2F0GAQgASgDEg4KBnJ0dF9tcxgFIAEoDSLwAQoMVHVubmVsU3RhdHVzEgoKAmlkGAEgASgJEgwKBHNsdWcYAiABKAkSDAoEbmFtZRgDIAEoCRISCgp0YXJnZXRfdXJsGAQgASgJEhMKC3B1YmxpY19wYXRoGAUgASgJEhIKCmNyZWF0ZWRfYXQYBiABKAMSEgoKZXhwaXJlc19hdBgHIAEoAxIaChJhY3RpdmVfY29ubmVjdGlvbnMYCCABKA0SGAoQcHJvamVjdF9wYXRoX2tleRgJIAEoCRIxCgVsb2NhbBgKIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWx0aCKlAQoTVHVubmVsU3RhdGVTbmFwc2hvdBIzCgd0dW5uZWxzGAEgAygLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsU3RhdHVzEhAKCHJldmlzaW9uGAIgASgEEhQKDGFnZW50X29ubGluZRgDIAEoCBIxCgVyZWxheRgEIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWx0aCKZAQoOVHVubmVsTXV0YXRpb24SDgoGYWN0aW9uGAEgASgJEhEKCXR1bm5lbF9pZBgCIAEoCRISCgp0YXJnZXRfdXJsGAMgASgJEgwKBG5hbWUYBCABKAkSGAoLdHRsX3NlY29uZHMYBSABKA1IAIgBARIYChBwcm9qZWN0X3BhdGhfa2V5GAYgASgJQg4KDF90dGxfc2Vjb25kcyJUChRUdW5uZWxNdXRhdGlvblJlc3VsdBIRCgl0dW5uZWxfaWQYASABKAkSEgoKZXJyb3JfY29kZRgCIAEoCRIVCg1lcnJvcl9tZXNzYWdlGAMgASgJIlkKEVR1bm5lbFByb2JlUmVzdWx0EhEKCXR1bm5lbF9pZBgBIAEoCRIxCgVsb2NhbBgCIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWx0aCJNChFUdW5uZWxQcm9iZVJlcG9ydBI4CgdyZXN1bHRzGAEgAygLMicubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsUHJvYmVSZXN1bHQiKwoMVHVubmVsSGVhZGVyEgwKBG5hbWUYASABKAkSDQoFdmFsdWUYAiABKAki9QIKC1R1bm5lbEZyYW1lEhEKCXN0cmVhbV9pZBgBIAEoCRIzCgRraW5kGAIgASgOMiUubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsRnJhbWVLaW5kEhIKCnRhcmdldF91cmwYAyABKAkSDgoGbWV0aG9kGAQgASgJEgwKBHBhdGgYBSABKAkSMwoHaGVhZGVycxgGIAMoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWRlchIOCgZzdGF0dXMYByABKA0SDAoEYm9keRgIIAEoDBINCgVlcnJvchgJIAEoCRJCCg93c19tZXNzYWdlX3R5cGUYCiABKA4yKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UdW5uZWxXc01lc3NhZ2VUeXBlEhYKDndzX3N1YnByb3RvY29sGAsgASgJEhUKDXdzX2Nsb3NlX2NvZGUYDCABKA0SFwoPd3NfY2xvc2VfcmVhc29uGA0gASgJIikKFVdvcmtzcGFjZVdhdGNoUmVxdWVzdBIQCgh3b3JrZGlycxgBIAMoCSJ+ChZXb3Jrc3BhY2VBY3Rpdml0eUV2ZW50Eg8KB3dvcmtkaXIYASABKAkSEAoIcmV2aXNpb24YAiABKAQSCgoCZnMYAyABKAgSCwoDZ2l0GAQgASgIEhUKDWNoYW5nZWRfcGF0aHMYBSADKAkSEQoJdHJ1bmNhdGVkGAYgASgIIpYCChRNYW5hZ2VkUHJvY2Vzc1JlY29yZBIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRIPCgdjb21tYW5kGAMgASgJEgsKA2N3ZBgEIAEoCRINCgVzaGVsbBgFIAEoCRILCgNwaWQYBiABKA0SEAoIbG9nX3BhdGgYByABKAkSEgoKc3RhcnRlZF9hdBgIIAEoAxIYCgtmaW5pc2hlZF9hdBgJIAEoA0gAiAEBEhYKCWV4aXRfY29kZRgKIAEoBUgBiAEBEg8KB3J1bm5pbmcYCyABKAgSEAoIaXNvbGF0ZWQYDCABKAgSEAoIcmVzdG9yZWQYDSABKAhCDgoMX2ZpbmlzaGVkX2F0QgwKCl9leGl0X2NvZGUiaQoWTWFuYWdlZFByb2Nlc3NTbmFwc2hvdBI9Cglwcm9jZXNzZXMYASADKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5NYW5hZ2VkUHJvY2Vzc1JlY29yZBIQCghyZXZpc2lvbhgCIAEoBCJOChVNYW5hZ2VkUHJvY2Vzc1JlcXVlc3QSDgoGYWN0aW9uGAEgASgJEhIKCnByb2Nlc3NfaWQYAiABKAkSEQoJbWF4X2J5dGVzGAMgASgNIrcBChZNYW5hZ2VkUHJvY2Vzc1Jlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRI+CghzbmFwc2hvdBgCIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLk1hbmFnZWRQcm9jZXNzU25hcHNob3QSEwoLbG9nX2NvbnRlbnQYAyABKAkSEAoIbG9nX3BhdGgYBCABKAkSFQoNbG9nX3RydW5jYXRlZBgFIAEoCBIPCgdzdG9wcGVkGAYgASgIIjkKE01lbW9yeU1hbmFnZVJlcXVlc3QSDwoHY29tbWFuZBgBIAEoCRIRCglhcmdzX2pzb24YAiABKAkiKwoUTWVtb3J5TWFuYWdlUmVzcG9uc2USEwoLcmVzdWx0X2pzb24YASABKAkixgIKD1Rlcm1pbmFsUmVxdWVzdBIOCgZhY3Rpb24YASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAMgASgJEgsKA2N3ZBgEIAEoCRINCgVzaGVsbBgFIAEoCRINCgV0aXRsZRgGIAEoCRIMCgRkYXRhGAcgASgJEgwKBGNvbHMYCCABKA0SDAoEcm93cxgJIAEoDRIRCgltYXhfYnl0ZXMYCiABKA0SEwoLc3NoX2hvc3RfaWQYCyABKAkSEQoJcHJvbXB0X2lkGAwgASgJEhUKDXByb21wdF9hbnN3ZXIYDSABKAkSFgoOdHJ1c3RfaG9zdF9rZXkYDiABKAgSFAoMc2Z0cF9lbmFibGVkGA8gASgIEg4KBnRhYl9pZBgQIAEoCRIQCgh0YWJfa2luZBgRIAEoCSKyAgoPVGVybWluYWxTZXNzaW9uEgoKAmlkGAEgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYAiABKAkSCwoDY3dkGAMgASgJEg0KBXNoZWxsGAQgASgJEg0KBXRpdGxlGAUgASgJEgsKA3BpZBgGIAEoDRIMCgRjb2xzGAcgASgNEgwKBHJvd3MYCCABKA0SEgoKY3JlYXRlZF9hdBgJIAEoBBISCgp1cGRhdGVkX2F0GAogASgEEhMKC2ZpbmlzaGVkX2F0GAsgASgEEhEKCWV4aXRfY29kZRgMIAEoBRIPCgdydW5uaW5nGA0gASgIEgwKBGtpbmQYDiABKAkSNgoDc3NoGA8gASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTc2hNZXRhZGF0YSLbAQoTVGVybWluYWxTc2hNZXRhZGF0YRIPCgdob3N0X2lkGAEgASgJEhEKCWhvc3RfbmFtZRgCIAEoCRIQCgh1c2VybmFtZRgDIAEoCRIMCgRob3N0GAQgASgJEgwKBHBvcnQYBSABKA0SEQoJYXV0aF90eXBlGAYgASgJEg4KBnN0YXR1cxgHIAEoCRIZChFyZWNvbm5lY3RfYXR0ZW1wdBgIIAEoDRIeChZyZWNvbm5lY3RfbWF4X2F0dGVtcHRzGAkgASgNEhQKDHNmdHBfZW5hYmxlZBgKIAEoCCL3AQoLU2Z0cFJlcXVlc3QSDgoGYWN0aW9uGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgDIAEoCRIPCgd3b3JrZGlyGAQgASgJEhIKCmxvY2FsX3BhdGgYBSABKAkSEwoLcmVtb3RlX3BhdGgYBiABKAkSEQoJZnJvbV9wYXRoGAcgASgJEg8KB3RvX3BhdGgYCCABKAkSEQoJZGlyZWN0aW9uGAkgASgJEhMKC3RhcmdldF9wYXRoGAogASgJEhEKCXJlY3Vyc2l2ZRgLIAEoCBIRCglvdmVyd3JpdGUYDCABKAgiWAoJU2Z0cEVudHJ5EgwKBHBhdGgYASABKAkSDAoEbmFtZRgCIAEoCRIMCgRraW5kGAMgASgJEhIKCnNpemVfYnl0ZXMYBCABKAQSDQoFbXRpbWUYBSABKAQi8gEKDFNmdHBUcmFuc2ZlchIKCgJpZBgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEhEKCWRpcmVjdGlvbhgDIAEoCRIOCgZzdGF0dXMYBCABKAkSEwoLc291cmNlX3BhdGgYBSABKAkSEwoLdGFyZ2V0X3BhdGgYBiABKAkSFAoMY3VycmVudF9wYXRoGAcgASgJEhIKCmJ5dGVzX2RvbmUYCCABKAQSEwoLYnl0ZXNfdG90YWwYCSABKAQSEgoKZmlsZXNfZG9uZRgKIAEoDRITCgtmaWxlc190b3RhbBgLIAEoDRINCgVlcnJvchgMIAEoCSLUAQoMU2Z0cFJlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRIMCgRwYXRoGAIgASgJEjAKB2VudHJpZXMYAyADKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZnRwRW50cnkSLgoFZW50cnkYBCABKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZnRwRW50cnkSDgoGZXhpc3RzGAUgASgIEjQKCHRyYW5zZmVyGAYgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuU2Z0cFRyYW5zZmVyIk8KCVNmdHBFdmVudBIMCgRraW5kGAEgASgJEjQKCHRyYW5zZmVyGAIgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuU2Z0cFRyYW5zZmVyIsEBChFUZXJtaW5hbFNzaFByb21wdBIKCgJpZBgBIAEoCRIMCgRraW5kGAIgASgJEg8KB2hvc3RfaWQYAyABKAkSEQoJaG9zdF9uYW1lGAQgASgJEgwKBGhvc3QYBSABKAkSDAoEcG9ydBgGIAEoDRIPCgdtZXNzYWdlGAcgASgJEhoKEmZpbmdlcnByaW50X3NoYTI1NhgIIAEoCRIQCghrZXlfdHlwZRgJIAEoCRITCgthbnN3ZXJfZWNobxgKIAEoCCJBChNUZXJtaW5hbFNoZWxsT3B0aW9uEgoKAmlkGAEgASgJEg0KBWxhYmVsGAIgASgJEg8KB2NvbW1hbmQYAyABKAkigAEKDlRlcm1pbmFsU3NoVGFiEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgDIAEoCRIMCgRraW5kGAQgASgJEhIKCmNyZWF0ZWRfYXQYBSABKAQSEgoKdXBkYXRlZF9hdBgGIAEoBCJ/ChdUZXJtaW5hbFNzaFRhYnNTbmFwc2hvdBIYChBwcm9qZWN0X3BhdGhfa2V5GAEgASgJEjIKBHRhYnMYAiADKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNzaFRhYhIQCghyZXZpc2lvbhgEIAEoBEoECAMQBCLZAwoQVGVybWluYWxSZXNwb25zZRIOCgZhY3Rpb24YASABKAkSNwoIc2Vzc2lvbnMYAiADKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNlc3Npb24SNgoHc2Vzc2lvbhgDIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsU2Vzc2lvbhIOCgZvdXRwdXQYBCABKAwSEQoJdHJ1bmNhdGVkGAUgASgIEkAKDXNoZWxsX29wdGlvbnMYBiADKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNoZWxsT3B0aW9uEhUKDWRlZmF1bHRfc2hlbGwYByABKAkSGwoTb3V0cHV0X3N0YXJ0X29mZnNldBgIIAEoBBIZChFvdXRwdXRfZW5kX29mZnNldBgJIAEoBBI7Cgpzc2hfcHJvbXB0GAogASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTc2hQcm9tcHQSEgoKbGF0ZW5jeV9tcxgLIAEoDRI/Cghzc2hfdGFicxgMIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsU3NoVGFic1NuYXBzaG90IooCCg1UZXJtaW5hbEV2ZW50EgwKBGtpbmQYASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAMgASgJEjYKB3Nlc3Npb24YBCABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNlc3Npb24SDAoEZGF0YRgFIAEoDBIbChNvdXRwdXRfc3RhcnRfb2Zmc2V0GAYgASgEEhkKEW91dHB1dF9lbmRfb2Zmc2V0GAcgASgEEj8KCHNzaF90YWJzGAggASgLMi0ubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTc2hUYWJzU25hcHNob3QisgIKE1Rlcm1pbmFsU3RyZWFtRnJhbWUSDAoEa2luZBgBIAEoCRIRCglzdHJlYW1faWQYAiABKAkSEgoKc2Vzc2lvbl9pZBgDIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAQgASgJEgsKA3NlcRgFIAEoBBIUCgxzdGFydF9vZmZzZXQYBiABKAQSEgoKZW5kX29mZnNldBgHIAEoBBIMCgRjb2xzGAggASgNEgwKBHJvd3MYCSABKA0SEQoJbWF4X2J5dGVzGAogASgNEhEKCXRydW5jYXRlZBgLIAEoCBINCgVlcnJvchgMIAEoCRI2CgdzZXNzaW9uGA0gASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTZXNzaW9uEgwKBGRhdGEYDiABKAwiQAoKR2l0UmVxdWVzdBIOCgZhY3Rpb24YASABKAkSDwoHd29ya2RpchgCIAEoCRIRCglhcmdzX2pzb24YAyABKAkiMgoLR2l0UmVzcG9uc2USDgoGYWN0aW9uGAEgASgJEhMKC3Jlc3VsdF9qc29uGAIgASgJIvYCCgtDaGF0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDwoHbWVzc2FnZRgCIAEoCRI/Cg5zZWxlY3RlZF9tb2RlbBgDIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRTZWxlY3RlZE1vZGVsEhYKDmV4ZWN1dGlvbl9tb2RlGAQgASgJEg8KB3dvcmtkaXIYBSABKAkSHQoVc2VsZWN0ZWRfc3lzdGVtX3Rvb2xzGAYgAygJEj4KDnVwbG9hZGVkX2ZpbGVzGAcgAygLMiYubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdFVwbG9hZGVkRmlsZRIZChFjbGllbnRfcmVxdWVzdF9pZBgIIAEoCRJDChBydW50aW1lX2NvbnRyb2xzGAkgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdFJ1bnRpbWVDb250cm9scxIUCgxxdWV1ZV9wb2xpY3kYCiABKAkiigEKDkNoYXRNZXNzYWdlUmVmEhUKDXNlZ21lbnRfaW5kZXgYASABKAUSFQoNbWVzc2FnZV9pbmRleBgCIAEoBRISCgpzZWdtZW50X2lkGAMgASgJEhIKCm1lc3NhZ2VfaWQYBCABKAkSDAoEcm9sZRgFIAEoCRIUCgxjb250ZW50X2hhc2gYBiABKAkiPAoRQ2FuY2VsQ2hhdFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg4KBnJ1bl9pZBgCIAEoCSLPAQoSQ2hhdENvbW1hbmRSZXF1ZXN0EgwKBHR5cGUYASABKAkSMgoHcmVxdWVzdBgCIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRSZXF1ZXN0Ej4KEGJhc2VfbWVzc2FnZV9yZWYYAyABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0TWVzc2FnZVJlZhI3CgZjYW5jZWwYBCABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DYW5jZWxDaGF0UmVxdWVzdCK4AQoQQ2hhdFF1ZXVlUmVxdWVzdBIOCgZhY3Rpb24YASABKAkSFwoPY29udmVyc2F0aW9uX2lkGAIgASgJEg8KB2l0ZW1faWQYAyABKAkSEQoJZGlyZWN0aW9uGAQgASgJEhAKCHJldmlzaW9uGAUgASgEEhIKCmRyYWZ0X2pzb24YBiABKAkSGwoTdXBsb2FkZWRfZmlsZXNfanNvbhgHIAEoCRIUCgxyZXF1ZXN0X2pzb24YCCABKAkihgEKEUNoYXRRdWV1ZVJlc3BvbnNlEhAKCGFjY2VwdGVkGAEgASgIEg8KB21lc3NhZ2UYAiABKAkSFQoNc25hcHNob3RfanNvbhgDIAEoCRIRCglpdGVtX2pzb24YBCABKAkSEgoKZXJyb3JfY29kZRgFIAEoCRIQCghyZXZpc2lvbhgGIAEoBCJSCg5DaGF0UXVldWVFdmVudBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNc25hcHNob3RfanNvbhgCIAEoCRIQCghyZXZpc2lvbhgDIAEoBCKFAgoJQ2hhdEV2ZW50EjsKBHR5cGUYASABKA4yLS5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0RXZlbnQuQ2hhdEV2ZW50VHlwZRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSDAoEZGF0YRgDIAEoCSKTAQoNQ2hhdEV2ZW50VHlwZRIJCgVUT0tFThAAEgwKCFRISU5LSU5HEAESDQoJVE9PTF9DQUxMEAISDwoLVE9PTF9SRVNVTFQQAxIICgRET05FEAQSCQoFRVJST1IQBRIPCgtUT09MX1NUQVRVUxAGEhEKDUhPU1RFRF9TRUFSQ0gQBxIQCgxVU0VSX01FU1NBR0UQCCK8AQoQQ2hhdENvbnRyb2xFdmVudBISCgpyZXF1ZXN0X2lkGAEgASgJEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAIgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgDIAEoCRIRCglydW5fZXBvY2gYBCABKAMSDAoEdHlwZRgFIAEoCRINCgVzdGF0ZRgGIAEoCRISCgplcnJvcl9jb2RlGAcgASgJEg8KB21lc3NhZ2UYCCABKAkSCwoDc2VxGAkgASgDIvwBChNDaGF0UnVudGltZVNuYXBzaG90EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIOCgZydW5faWQYAiABKAkSGQoRY2xpZW50X3JlcXVlc3RfaWQYAyABKAkSEQoJd29ya2VyX2lkGAQgASgJEg0KBXN0YXRlGAUgASgJEgsKA2N3ZBgGIAEoCRISCgp1cGRhdGVkX2F0GAcgASgDEhAKCHJldmlzaW9uGAggASgDEhQKDGVudHJpZXNfanNvbhgJIAEoCRITCgt0b29sX3N0YXR1cxgKIAEoCRIhChl0b29sX3N0YXR1c19pc19jb21wYWN0aW9uGAsgASgIIuoBChJSdW50aW1lU3RhdHVzRXZlbnQSEQoJd29ya2VyX2lkGAEgASgJEg0KBXN0YXRlGAIgASgJEg8KB3Zpc2libGUYAyABKAgSGAoQYWN0aXZlX3J1bl9jb3VudBgEIAEoDRIRCgl0aW1lc3RhbXAYBSABKAMSOAoLYWN0aXZlX3J1bnMYBiADKAsyIy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0UnVuUmVwb3J0EjoKDWZpbmlzaGVkX3J1bnMYByADKAsyIy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0UnVuUmVwb3J0IoABCg1DaGF0UnVuUmVwb3J0Eg4KBnJ1bl9pZBgBIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSDQoFc3RhdGUYAyABKAkSEgoKZXJyb3JfY29kZRgEIAEoCRIPCgdtZXNzYWdlGAUgASgJEhIKCnVwZGF0ZWRfYXQYBiABKAMiRwoRQ3Jvbk1hbmFnZVJlcXVlc3QSDgoGYWN0aW9uGAEgASgJEg8KB3Rhc2tfaWQYAiABKAkSEQoJdGFza19qc29uGAMgASgJIjkKEkNyb25NYW5hZ2VSZXNwb25zZRIOCgZhY3Rpb24YASABKAkSEwoLcmVzdWx0X2pzb24YAiABKAkiVQoSSGlzdG9yeUxpc3RSZXF1ZXN0EgwKBHBhZ2UYASABKAUSEQoJcGFnZV9zaXplGAIgASgFEgsKA2N3ZBgDIAEoCRIRCgljd2RfZW1wdHkYBCABKAgibAoTSGlzdG9yeUxpc3RSZXNwb25zZRJACg1jb252ZXJzYXRpb25zGAEgAygLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeRITCgt0b3RhbF9jb3VudBgCIAEoBSKKAgoTQ29udmVyc2F0aW9uU3VtbWFyeRIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRISCgpjcmVhdGVkX2F0GAMgASgDEhIKCnVwZGF0ZWRfYXQYBCABKAMSFQoNbWVzc2FnZV9jb3VudBgFIAEoBRITCgtwcm92aWRlcl9pZBgGIAEoCRINCgVtb2RlbBgHIAEoCRISCgpzZXNzaW9uX2lkGAggASgJEgsKA2N3ZBgJIAEoCRIRCglpc19waW5uZWQYCiABKAgSEQoJcGlubmVkX2F0GAsgASgDEhEKCWlzX3NoYXJlZBgMIAEoCBIbChNzZWxlY3RlZF9tb2RlbF9qc29uGA0gASgJIkIKEUhpc3RvcnlHZXRSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIUCgxtYXhfbWVzc2FnZXMYAiABKAUi1AEKEkhpc3RvcnlHZXRSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEh4KFnJldHVybmVkX21lc3NhZ2VfY291bnQYBCABKAUSEAoIaGFzX21vcmUYBSABKAgSPwoMY29udmVyc2F0aW9uGAYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeSKFAQoUSGlzdG9yeVByZWZpeFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhQKDG1heF9tZXNzYWdlcxgCIAEoBRI+ChBiYXNlX21lc3NhZ2VfcmVmGAMgASgLMiQubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdE1lc3NhZ2VSZWYi1wEKFUhpc3RvcnlQcmVmaXhSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEh4KFnJldHVybmVkX21lc3NhZ2VfY291bnQYBCABKAUSEAoIaGFzX21vcmUYBSABKAgSPwoMY29udmVyc2F0aW9uGAYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeSI+ChRIaXN0b3J5UmVuYW1lUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDQoFdGl0bGUYAiABKAkiWAoVSGlzdG9yeVJlbmFtZVJlc3BvbnNlEj8KDGNvbnZlcnNhdGlvbhgBIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNvbnZlcnNhdGlvblN1bW1hcnkibwoUSGlzdG9yeUJyYW5jaFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEj4KEGJhc2VfbWVzc2FnZV9yZWYYAiABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0TWVzc2FnZVJlZiJYChVIaXN0b3J5QnJhbmNoUmVzcG9uc2USPwoMY29udmVyc2F0aW9uGAEgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeSI/ChFIaXN0b3J5UGluUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSEQoJaXNfcGlubmVkGAIgASgIIlUKEkhpc3RvcnlQaW5SZXNwb25zZRI/Cgxjb252ZXJzYXRpb24YASABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5Db252ZXJzYXRpb25TdW1tYXJ5IpIBChJIaXN0b3J5U2hhcmVTdGF0dXMSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg8KB2VuYWJsZWQYAiABKAgSDQoFdG9rZW4YAyABKAkSEgoKY3JlYXRlZF9hdBgEIAEoAxISCgp1cGRhdGVkX2F0GAUgASgDEhsKE3JlZGFjdF90b29sX2NvbnRlbnQYBiABKAgiMQoWSGlzdG9yeVNoYXJlR2V0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiUgoXSGlzdG9yeVNoYXJlR2V0UmVzcG9uc2USNwoFc2hhcmUYASABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5U2hhcmVTdGF0dXMifAoWSGlzdG9yeVNoYXJlU2V0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDwoHZW5hYmxlZBgCIAEoCBIgChNyZWRhY3RfdG9vbF9jb250ZW50GAMgASgISACIAQFCFgoUX3JlZGFjdF90b29sX2NvbnRlbnQiUgoXSGlzdG9yeVNoYXJlU2V0UmVzcG9uc2USNwoFc2hhcmUYASABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5U2hhcmVTdGF0dXMiKwoaSGlzdG9yeVNoYXJlUmVzb2x2ZVJlcXVlc3QSDQoFdG9rZW4YASABKAkiyAEKG0hpc3RvcnlTaGFyZVJlc29sdmVSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEj8KDGNvbnZlcnNhdGlvbhgEIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNvbnZlcnNhdGlvblN1bW1hcnkSGwoTcmVkYWN0X3Rvb2xfY29udGVudBgFIAEoCCIYChZIaXN0b3J5V29ya2RpcnNSZXF1ZXN0IlUKFUhpc3RvcnlXb3JrZGlyU3VtbWFyeRIMCgRwYXRoGAEgASgJEhoKEmNvbnZlcnNhdGlvbl9jb3VudBgCIAEoBRISCgp1cGRhdGVkX2F0GAMgASgDIlgKF0hpc3RvcnlXb3JrZGlyc1Jlc3BvbnNlEj0KCHdvcmtkaXJzGAEgAygLMisubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVdvcmtkaXJTdW1tYXJ5Ii8KFEhpc3RvcnlEZWxldGVSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCSIXChVIaXN0b3J5RGVsZXRlUmVzcG9uc2UiegoQSGlzdG9yeVN5bmNFdmVudBIMCgRraW5kGAEgASgJEj8KDGNvbnZlcnNhdGlvbhgCIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNvbnZlcnNhdGlvblN1bW1hcnkSFwoPY29udmVyc2F0aW9uX2lkGAMgASgJIhUKE1Byb3ZpZGVyTGlzdFJlcXVlc3QiLgoUUHJvdmlkZXJMaXN0UmVzcG9uc2USFgoOcHJvdmlkZXJzX2pzb24YASABKAkiFAoSU2V0dGluZ3NHZXRSZXF1ZXN0IiwKE1NldHRpbmdzR2V0UmVzcG9uc2USFQoNc2V0dGluZ3NfanNvbhgBIAEoCSIuChVTZXR0aW5nc1VwZGF0ZVJlcXVlc3QSFQoNc2V0dGluZ3NfanNvbhgBIAEoCSI7ChZTZXR0aW5nc1VwZGF0ZVJlc3BvbnNlEhAKCGFjY2VwdGVkGAEgASgIEg8KB21lc3NhZ2UYAiABKAkiPgogU2V0dGluZ3NSZXNldFNzaEtub3duSG9zdFJlcXVlc3QSDAoEaG9zdBgBIAEoCRIMCgRwb3J0GAIgASgNIjQKIVNldHRpbmdzUmVzZXRTc2hLbm93bkhvc3RSZXNwb25zZRIPCgdkZWxldGVkGAEgASgNIioKEVNldHRpbmdzU3luY0V2ZW50EhUKDXNldHRpbmdzX2pzb24YASABKAkiFwoVU2tpbGxGaWxlc0xpc3RSZXF1ZXN0IkwKFlNraWxsRmlsZXNMaXN0UmVzcG9uc2USEAoIcm9vdF9kaXIYASABKAkSDQoFcGF0aHMYAiADKAkSEQoJdHJ1bmNhdGVkGAMgASgIIigKGFNraWxsTWV0YWRhdGFSZWFkUmVxdWVzdBIMCgRwYXRoGAEgASgJIj4KGVNraWxsTWV0YWRhdGFSZWFkUmVzcG9uc2USDAoEbmFtZRgBIAEoCRITCgtkZXNjcmlwdGlvbhgCIAEoCSJEChRTa2lsbFRleHRSZWFkUmVxdWVzdBIMCgRwYXRoGAEgASgJEg4KBm9mZnNldBgCIAEoDRIOCgZsZW5ndGgYAyABKA0iOwoVU2tpbGxUZXh0UmVhZFJlc3BvbnNlEg8KB2NvbnRlbnQYASABKAkSEQoJdHJ1bmNhdGVkGAIgASgIIioKElNraWxsTWFuYWdlUmVxdWVzdBIUCgxwYXlsb2FkX2pzb24YASABKAkiKgoTU2tpbGxNYW5hZ2VSZXNwb25zZRITCgtyZXN1bHRfanNvbhgBIAEoCSJ3ChZGaWxlTWVudGlvbkxpc3RSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSEwoLbWF4X3Jlc3VsdHMYAiABKA0SDQoFcXVlcnkYAyABKAkSGAoLc2hvd19oaWRkZW4YBCABKAhIAIgBAUIOCgxfc2hvd19oaWRkZW4iPgoQRmlsZU1lbnRpb25FbnRyeRIMCgRwYXRoGAEgASgJEgwKBGtpbmQYAiABKAkSDgoGaGlkZGVuGAMgASgIImUKF0ZpbGVNZW50aW9uTGlzdFJlc3BvbnNlEjcKB2VudHJpZXMYASADKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5GaWxlTWVudGlvbkVudHJ5EhEKCXRydW5jYXRlZBgCIAEoCCI/CgZGc1Jvb3QSCgoCaWQYASABKAkSDAoEcGF0aBgCIAEoCRIMCgRraW5kGAMgASgJEg0KBWxhYmVsGAQgASgJIhAKDkZzUm9vdHNSZXF1ZXN0Ij4KD0ZzUm9vdHNSZXNwb25zZRIrCgVyb290cxgBIAMoCzIcLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUm9vdCI2ChFGc0xpc3REaXJzUmVxdWVzdBIMCgRwYXRoGAEgASgJEhMKC21heF9yZXN1bHRzGAIgASgNIigKCkZzRGlyRW50cnkSDAoEcGF0aBgBIAEoCRIMCgRuYW1lGAIgASgJImgKEkZzTGlzdERpcnNSZXNwb25zZRIMCgRwYXRoGAEgASgJEjEKB2VudHJpZXMYAiADKAsyIC5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0RpckVudHJ5EhEKCXRydW5jYXRlZBgDIAEoCCI8ChxGc0NyZWF0ZVByb2plY3RGb2xkZXJSZXF1ZXN0Eg4KBnBhcmVudBgBIAEoCRIMCgRuYW1lGAIgASgJIi0KHUZzQ3JlYXRlUHJvamVjdEZvbGRlclJlc3BvbnNlEgwKBHBhdGgYASABKAkijAEKDUZzTGlzdFJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJEg0KBWRlcHRoGAMgASgNEg4KBm9mZnNldBgEIAEoDRITCgttYXhfcmVzdWx0cxgFIAEoDRIYCgtzaG93X2hpZGRlbhgGIAEoCEgAiAEBQg4KDF9zaG93X2hpZGRlbiI5CgtGc0xpc3RFbnRyeRIMCgRwYXRoGAEgASgJEgwKBGtpbmQYAiABKAkSDgoGaGlkZGVuGAMgASgIIrkBCg5Gc0xpc3RSZXNwb25zZRIMCgRwYXRoGAEgASgJEhAKCGhhc19wYXRoGAIgASgIEg0KBWRlcHRoGAMgASgNEg4KBm9mZnNldBgEIAEoDRITCgttYXhfcmVzdWx0cxgFIAEoDRINCgV0b3RhbBgGIAEoDRIQCghoYXNfbW9yZRgHIAEoCBIyCgdlbnRyaWVzGAggAygLMiEubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNMaXN0RW50cnkiOgoZRnNSZWFkRWRpdGFibGVUZXh0UmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkijAEKGkZzUmVhZEVkaXRhYmxlVGV4dFJlc3BvbnNlEgwKBHBhdGgYASABKAkSDwoHY29udGVudBgCIAEoCRIQCghtdGltZV9tcxgDIAEoBBIUCgxjb250ZW50X2hhc2gYBCABKAkSEgoKc2l6ZV9ieXRlcxgFIAEoBBITCgt0b3RhbF9saW5lcxgGIAEoBCI8ChtGc1JlYWRXb3Jrc3BhY2VJbWFnZVJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJIokBChxGc1JlYWRXb3Jrc3BhY2VJbWFnZVJlc3BvbnNlEgwKBHBhdGgYASABKAkSEQoJbWltZV90eXBlGAIgASgJEgwKBGRhdGEYAyABKAkSEgoKc2l6ZV9ieXRlcxgEIAEoBBIQCghtdGltZV9tcxgFIAEoBBIUCgxjb250ZW50X2hhc2gYBiABKAkizgEKEkZzV3JpdGVUZXh0UmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkSDwoHY29udGVudBgDIAEoCRIMCgRtb2RlGAQgASgJEhkKEWV4cGVjdGVkX210aW1lX21zGAUgASgEEh0KFWV4cGVjdGVkX2NvbnRlbnRfaGFzaBgGIAEoCRIdChVoYXNfZXhwZWN0ZWRfbXRpbWVfbXMYByABKAgSIQoZaGFzX2V4cGVjdGVkX2NvbnRlbnRfaGFzaBgIIAEoCCKdAQoTRnNXcml0ZVRleHRSZXNwb25zZRIMCgRwYXRoGAEgASgJEgwKBG1vZGUYAiABKAkSFgoOZXhpc3RlZF9iZWZvcmUYAyABKAgSFQoNYnl0ZXNfd3JpdHRlbhgEIAEoBBIQCghtdGltZV9tcxgFIAEoBBIUCgxjb250ZW50X2hhc2gYBiABKAkSEwoLdG90YWxfbGluZXMYByABKAQiMwoSRnNDcmVhdGVEaXJSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCSIxChNGc0NyZWF0ZURpclJlc3BvbnNlEgwKBHBhdGgYASABKAkSDAoEa2luZBgCIAEoCSJGCg9Gc1JlbmFtZVJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIRCglmcm9tX3BhdGgYAiABKAkSDwoHdG9fcGF0aBgDIAEoCSJBChBGc1JlbmFtZVJlc3BvbnNlEhEKCWZyb21fcGF0aBgBIAEoCRIMCgRwYXRoGAIgASgJEgwKBGtpbmQYAyABKAkiMAoPRnNEZWxldGVSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCSIuChBGc0RlbGV0ZVJlc3BvbnNlEgwKBHBhdGgYASABKAkSDAoEa2luZBgCIAEoCSIgCgtQaW5nUmVxdWVzdBIRCgl0aW1lc3RhbXAYASABKAMiIQoMUG9uZ1Jlc3BvbnNlEhEKCXRpbWVzdGFtcBgBIAEoAyIuCg1FcnJvclJlc3BvbnNlEgwKBGNvZGUYASABKAUSDwoHbWVzc2FnZRgCIAEoCSJrChVQcm92aWRlck1vZGVsc1JlcXVlc3QSFQoNcHJvdmlkZXJfdHlwZRgBIAEoCRIQCghiYXNlX3VybBgCIAEoCRIPCgdhcGlfa2V5GAMgASgJEhgKEHVzZV9zeXN0ZW1fcHJveHkYBCABKAgiLQoWUHJvdmlkZXJNb2RlbHNSZXNwb25zZRITCgttb2RlbHNfanNvbhgBIAEoCSrGBAoPVHVubmVsRnJhbWVLaW5kEiEKHVRVTk5FTF9GUkFNRV9LSU5EX1VOU1BFQ0lGSUVEEAASKAokVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVFVRVNUX1NUQVJUEAESJwojVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVFVRVNUX0JPRFkQAhImCiJUVU5ORUxfRlJBTUVfS0lORF9IVFRQX1JFUVVFU1RfRU5EEAMSKQolVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVNQT05TRV9TVEFSVBAEEigKJFRVTk5FTF9GUkFNRV9LSU5EX0hUVFBfUkVTUE9OU0VfQk9EWRAFEicKI1RVTk5FTF9GUkFNRV9LSU5EX0hUVFBfUkVTUE9OU0VfRU5EEAYSHQoZVFVOTkVMX0ZSQU1FX0tJTkRfV1NfRElBTBAHEiAKHFRVTk5FTF9GUkFNRV9LSU5EX1dTX0RJQUxfT0sQCBIjCh9UVU5ORUxfRlJBTUVfS0lORF9XU19ESUFMX0VSUk9SEAkSHgoaVFVOTkVMX0ZSQU1FX0tJTkRfV1NfRlJBTUUQChIeChpUVU5ORUxfRlJBTUVfS0lORF9XU19DTE9TRRALEhsKF1RVTk5FTF9GUkFNRV9LSU5EX0VSUk9SEAwSHAoYVFVOTkVMX0ZSQU1FX0tJTkRfQ0FOQ0VMEA0SGgoWVFVOTkVMX0ZSQU1FX0tJTkRfUElORxAOEhoKFlRVTk5FTF9GUkFNRV9LSU5EX1BPTkcQDyqBAQoTVHVubmVsV3NNZXNzYWdlVHlwZRImCiJUVU5ORUxfV1NfTUVTU0FHRV9UWVBFX1VOU1BFQ0lGSUVEEAASHwobVFVOTkVMX1dTX01FU1NBR0VfVFlQRV9URVhUEAESIQodVFVOTkVMX1dTX01FU1NBR0VfVFlQRV9CSU5BUlkQAjK8AgoMQWdlbnRHYXRld2F5El4KDEFnZW50Q29ubmVjdBIjLmxpdmVhZ2VudC5nYXRld2F5LnYxLkFnZW50RW52ZWxvcGUaJS5saXZlYWdlbnQuZ2F0ZXdheS52MS5HYXRld2F5RW52ZWxvcGUoATABEnAKFEFnZW50VGVybWluYWxDb25uZWN0EikubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTdHJlYW1GcmFtZRopLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsU3RyZWFtRnJhbWUoATABElUKDEF1dGhlbnRpY2F0ZRIhLmxpdmVhZ2VudC5nYXRld2F5LnYxLkF1dGhSZXF1ZXN0GiIubGl2ZWFnZW50LmdhdGV3YXkudjEuQXV0aFJlc3BvbnNlGgOIAgFCQFo+Z2l0aHViLmNvbS9saXZlYWdlbnQvYWdlbnQtZ2F0ZXdheS9pbnRlcm5hbC9wcm90by92MTtnYXRld2F5djFiBnByb3RvMw"); + +/** + * @generated from message liveagent.gateway.v1.AuthRequest + */ +export type AuthRequest = Message<"liveagent.gateway.v1.AuthRequest"> & { + /** + * @generated from field: string token = 1; + */ + token: string; + + /** + * @generated from field: string agent_id = 2; + */ + agentId: string; + + /** + * @generated from field: string agent_version = 3; + */ + agentVersion: string; +}; + +/** + * Describes the message liveagent.gateway.v1.AuthRequest. + * Use `create(AuthRequestSchema)` to create a new message. + */ +export const AuthRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 0); + +/** + * @generated from message liveagent.gateway.v1.AuthResponse + */ +export type AuthResponse = Message<"liveagent.gateway.v1.AuthResponse"> & { + /** + * @generated from field: bool success = 1; + */ + success: boolean; + + /** + * @generated from field: string message = 2; + */ + message: string; + + /** + * @generated from field: string session_id = 3; + */ + sessionId: string; +}; + +/** + * Describes the message liveagent.gateway.v1.AuthResponse. + * Use `create(AuthResponseSchema)` to create a new message. + */ +export const AuthResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 1); + +/** + * @generated from message liveagent.gateway.v1.GatewayEnvelope + */ +export type GatewayEnvelope = Message<"liveagent.gateway.v1.GatewayEnvelope"> & { + /** + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * @generated from field: int64 timestamp = 2; + */ + timestamp: bigint; + + /** + * @generated from oneof liveagent.gateway.v1.GatewayEnvelope.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v1.ChatCommandRequest chat_command = 10; + */ + value: ChatCommandRequest; + case: "chatCommand"; + } | { + /** + * @generated from field: liveagent.gateway.v1.CronManageRequest cron_manage = 20; + */ + value: CronManageRequest; + case: "cronManage"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryListRequest history_list = 30; + */ + value: HistoryListRequest; + case: "historyList"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryGetRequest history_get = 31; + */ + value: HistoryGetRequest; + case: "historyGet"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryRenameRequest history_rename = 32; + */ + value: HistoryRenameRequest; + case: "historyRename"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryDeleteRequest history_delete = 33; + */ + value: HistoryDeleteRequest; + case: "historyDelete"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryPrefixRequest history_prefix = 34; + */ + value: HistoryPrefixRequest; + case: "historyPrefix"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryPinRequest history_pin = 35; + */ + value: HistoryPinRequest; + case: "historyPin"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryShareGetRequest history_share_get = 36; + */ + value: HistoryShareGetRequest; + case: "historyShareGet"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryShareSetRequest history_share_set = 37; + */ + value: HistoryShareSetRequest; + case: "historyShareSet"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryShareResolveRequest history_share_resolve = 38; + */ + value: HistoryShareResolveRequest; + case: "historyShareResolve"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryWorkdirsRequest history_workdirs = 39; + */ + value: HistoryWorkdirsRequest; + case: "historyWorkdirs"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ProviderListRequest provider_list = 40; + */ + value: ProviderListRequest; + case: "providerList"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SettingsGetRequest settings_get = 41; + */ + value: SettingsGetRequest; + case: "settingsGet"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SettingsUpdateRequest settings_update = 42; + */ + value: SettingsUpdateRequest; + case: "settingsUpdate"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SkillFilesListRequest skill_files_list = 43; + */ + value: SkillFilesListRequest; + case: "skillFilesList"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SkillMetadataReadRequest skill_metadata_read = 44; + */ + value: SkillMetadataReadRequest; + case: "skillMetadataRead"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SkillTextReadRequest skill_text_read = 45; + */ + value: SkillTextReadRequest; + case: "skillTextRead"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FileMentionListRequest file_mention_list = 46; + */ + value: FileMentionListRequest; + case: "fileMentionList"; + } | { + /** + * @generated from field: liveagent.gateway.v1.UploadReadableFilesRequest upload_readable_files = 47; + */ + value: UploadReadableFilesRequest; + case: "uploadReadableFiles"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsRootsRequest fs_roots = 48; + */ + value: FsRootsRequest; + case: "fsRoots"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsListDirsRequest fs_list_dirs = 49; + */ + value: FsListDirsRequest; + case: "fsListDirs"; + } | { + /** + * @generated from field: liveagent.gateway.v1.PingRequest ping = 50; + */ + value: PingRequest; + case: "ping"; + } | { + /** + * @generated from field: liveagent.gateway.v1.UploadedImagePreviewRequest uploaded_image_preview = 51; + */ + value: UploadedImagePreviewRequest; + case: "uploadedImagePreview"; + } | { + /** + * @generated from field: liveagent.gateway.v1.MemoryManageRequest memory_manage = 52; + */ + value: MemoryManageRequest; + case: "memoryManage"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SkillManageRequest skill_manage = 53; + */ + value: SkillManageRequest; + case: "skillManage"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsCreateProjectFolderRequest fs_create_project_folder = 54; + */ + value: FsCreateProjectFolderRequest; + case: "fsCreateProjectFolder"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TerminalRequest terminal_request = 55; + */ + value: TerminalRequest; + case: "terminalRequest"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsListRequest fs_list = 56; + */ + value: FsListRequest; + case: "fsList"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsWriteTextRequest fs_write_text = 57; + */ + value: FsWriteTextRequest; + case: "fsWriteText"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsCreateDirRequest fs_create_dir = 58; + */ + value: FsCreateDirRequest; + case: "fsCreateDir"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsRenameRequest fs_rename = 59; + */ + value: FsRenameRequest; + case: "fsRename"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsDeleteRequest fs_delete = 60; + */ + value: FsDeleteRequest; + case: "fsDelete"; + } | { + /** + * @generated from field: liveagent.gateway.v1.GitRequest git_request = 61; + */ + value: GitRequest; + case: "gitRequest"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsReadEditableTextRequest fs_read_editable_text = 62; + */ + value: FsReadEditableTextRequest; + case: "fsReadEditableText"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsReadWorkspaceImageRequest fs_read_workspace_image = 63; + */ + value: FsReadWorkspaceImageRequest; + case: "fsReadWorkspaceImage"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SftpRequest sftp_request = 64; + */ + value: SftpRequest; + case: "sftpRequest"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ProviderModelsRequest provider_models = 65; + */ + value: ProviderModelsRequest; + case: "providerModels"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SettingsResetSshKnownHostRequest settings_reset_ssh_known_host = 72; + */ + value: SettingsResetSshKnownHostRequest; + case: "settingsResetSshKnownHost"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ChatQueueRequest chat_queue = 73; + */ + value: ChatQueueRequest; + case: "chatQueue"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TunnelStateSnapshot tunnel_state = 80; + */ + value: TunnelStateSnapshot; + case: "tunnelState"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TunnelMutation tunnel_mutation = 81; + */ + value: TunnelMutation; + case: "tunnelMutation"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TunnelFrame tunnel_frame = 82; + */ + value: TunnelFrame; + case: "tunnelFrame"; + } | { + /** + * @generated from field: liveagent.gateway.v1.WorkspaceWatchRequest workspace_watch = 90; + */ + value: WorkspaceWatchRequest; + case: "workspaceWatch"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ManagedProcessRequest managed_process_request = 91; + */ + value: ManagedProcessRequest; + case: "managedProcessRequest"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryBranchRequest history_branch = 92; + */ + value: HistoryBranchRequest; + case: "historyBranch"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v1.GatewayEnvelope. + * Use `create(GatewayEnvelopeSchema)` to create a new message. + */ +export const GatewayEnvelopeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 2); + +/** + * @generated from message liveagent.gateway.v1.AgentEnvelope + */ +export type AgentEnvelope = Message<"liveagent.gateway.v1.AgentEnvelope"> & { + /** + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * @generated from field: int64 timestamp = 2; + */ + timestamp: bigint; + + /** + * @generated from oneof liveagent.gateway.v1.AgentEnvelope.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v1.ChatEvent chat_event = 10; + */ + value: ChatEvent; + case: "chatEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v1.CronManageResponse cron_manage_resp = 20; + */ + value: CronManageResponse; + case: "cronManageResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryListResponse history_list_resp = 30; + */ + value: HistoryListResponse; + case: "historyListResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryGetResponse history_get_resp = 31; + */ + value: HistoryGetResponse; + case: "historyGetResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryRenameResponse history_rename_resp = 32; + */ + value: HistoryRenameResponse; + case: "historyRenameResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryDeleteResponse history_delete_resp = 33; + */ + value: HistoryDeleteResponse; + case: "historyDeleteResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistorySyncEvent history_sync = 34; + */ + value: HistorySyncEvent; + case: "historySync"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryPrefixResponse history_prefix_resp = 35; + */ + value: HistoryPrefixResponse; + case: "historyPrefixResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryPinResponse history_pin_resp = 36; + */ + value: HistoryPinResponse; + case: "historyPinResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryShareGetResponse history_share_get_resp = 37; + */ + value: HistoryShareGetResponse; + case: "historyShareGetResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryShareSetResponse history_share_set_resp = 38; + */ + value: HistoryShareSetResponse; + case: "historyShareSetResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryShareResolveResponse history_share_resolve_resp = 39; + */ + value: HistoryShareResolveResponse; + case: "historyShareResolveResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryWorkdirsResponse history_workdirs_resp = 56; + */ + value: HistoryWorkdirsResponse; + case: "historyWorkdirsResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ProviderListResponse provider_list_resp = 40; + */ + value: ProviderListResponse; + case: "providerListResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SettingsGetResponse settings_get_resp = 41; + */ + value: SettingsGetResponse; + case: "settingsGetResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SettingsUpdateResponse settings_update_resp = 42; + */ + value: SettingsUpdateResponse; + case: "settingsUpdateResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SettingsSyncEvent settings_sync = 43; + */ + value: SettingsSyncEvent; + case: "settingsSync"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SkillFilesListResponse skill_files_list_resp = 44; + */ + value: SkillFilesListResponse; + case: "skillFilesListResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SkillMetadataReadResponse skill_metadata_read_resp = 45; + */ + value: SkillMetadataReadResponse; + case: "skillMetadataReadResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SkillTextReadResponse skill_text_read_resp = 46; + */ + value: SkillTextReadResponse; + case: "skillTextReadResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FileMentionListResponse file_mention_list_resp = 47; + */ + value: FileMentionListResponse; + case: "fileMentionListResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.UploadReadableFilesResponse upload_readable_files_resp = 48; + */ + value: UploadReadableFilesResponse; + case: "uploadReadableFilesResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsRootsResponse fs_roots_resp = 49; + */ + value: FsRootsResponse; + case: "fsRootsResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.PongResponse pong = 50; + */ + value: PongResponse; + case: "pong"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsListDirsResponse fs_list_dirs_resp = 51; + */ + value: FsListDirsResponse; + case: "fsListDirsResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.UploadedImagePreviewResponse uploaded_image_preview_resp = 52; + */ + value: UploadedImagePreviewResponse; + case: "uploadedImagePreviewResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.MemoryManageResponse memory_manage_resp = 53; + */ + value: MemoryManageResponse; + case: "memoryManageResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SkillManageResponse skill_manage_resp = 54; + */ + value: SkillManageResponse; + case: "skillManageResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsCreateProjectFolderResponse fs_create_project_folder_resp = 55; + */ + value: FsCreateProjectFolderResponse; + case: "fsCreateProjectFolderResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TerminalResponse terminal_response = 57; + */ + value: TerminalResponse; + case: "terminalResponse"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TerminalEvent terminal_event = 58; + */ + value: TerminalEvent; + case: "terminalEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsListResponse fs_list_resp = 59; + */ + value: FsListResponse; + case: "fsListResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsWriteTextResponse fs_write_text_resp = 60; + */ + value: FsWriteTextResponse; + case: "fsWriteTextResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsCreateDirResponse fs_create_dir_resp = 61; + */ + value: FsCreateDirResponse; + case: "fsCreateDirResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsRenameResponse fs_rename_resp = 62; + */ + value: FsRenameResponse; + case: "fsRenameResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsDeleteResponse fs_delete_resp = 63; + */ + value: FsDeleteResponse; + case: "fsDeleteResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.GitResponse git_response = 64; + */ + value: GitResponse; + case: "gitResponse"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsReadEditableTextResponse fs_read_editable_text_resp = 65; + */ + value: FsReadEditableTextResponse; + case: "fsReadEditableTextResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.FsReadWorkspaceImageResponse fs_read_workspace_image_resp = 66; + */ + value: FsReadWorkspaceImageResponse; + case: "fsReadWorkspaceImageResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SftpResponse sftp_response = 73; + */ + value: SftpResponse; + case: "sftpResponse"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SftpEvent sftp_event = 74; + */ + value: SftpEvent; + case: "sftpEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ChatQueueResponse chat_queue_resp = 75; + */ + value: ChatQueueResponse; + case: "chatQueueResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ChatQueueEvent chat_queue_event = 76; + */ + value: ChatQueueEvent; + case: "chatQueueEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ChatControlEvent chat_control = 70; + */ + value: ChatControlEvent; + case: "chatControl"; + } | { + /** + * @generated from field: liveagent.gateway.v1.RuntimeStatusEvent runtime_status = 71; + */ + value: RuntimeStatusEvent; + case: "runtimeStatus"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SettingsResetSshKnownHostResponse settings_reset_ssh_known_host_resp = 72; + */ + value: SettingsResetSshKnownHostResponse; + case: "settingsResetSshKnownHostResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ChatRuntimeSnapshot chat_runtime_snapshot = 77; + */ + value: ChatRuntimeSnapshot; + case: "chatRuntimeSnapshot"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ProviderModelsResponse provider_models_resp = 79; + */ + value: ProviderModelsResponse; + case: "providerModelsResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TunnelDesiredState tunnel_desired = 80; + */ + value: TunnelDesiredState; + case: "tunnelDesired"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TunnelMutationResult tunnel_mutation_result = 81; + */ + value: TunnelMutationResult; + case: "tunnelMutationResult"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TunnelFrame tunnel_frame = 82; + */ + value: TunnelFrame; + case: "tunnelFrame"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TunnelProbeReport tunnel_probe_report = 83; + */ + value: TunnelProbeReport; + case: "tunnelProbeReport"; + } | { + /** + * @generated from field: liveagent.gateway.v1.WorkspaceActivityEvent workspace_activity = 90; + */ + value: WorkspaceActivityEvent; + case: "workspaceActivity"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ManagedProcessResponse managed_process_response = 91; + */ + value: ManagedProcessResponse; + case: "managedProcessResponse"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ManagedProcessSnapshot managed_process_snapshot = 92; + */ + value: ManagedProcessSnapshot; + case: "managedProcessSnapshot"; + } | { + /** + * @generated from field: liveagent.gateway.v1.HistoryBranchResponse history_branch_resp = 93; + */ + value: HistoryBranchResponse; + case: "historyBranchResp"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ErrorResponse error = 99; + */ + value: ErrorResponse; + case: "error"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v1.AgentEnvelope. + * Use `create(AgentEnvelopeSchema)` to create a new message. + */ +export const AgentEnvelopeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 3); + +/** + * @generated from message liveagent.gateway.v1.ChatSelectedModel + */ +export type ChatSelectedModel = Message<"liveagent.gateway.v1.ChatSelectedModel"> & { + /** + * @generated from field: string custom_provider_id = 1; + */ + customProviderId: string; + + /** + * @generated from field: string model = 2; + */ + model: string; + + /** + * @generated from field: string provider_type = 3; + */ + providerType: string; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatSelectedModel. + * Use `create(ChatSelectedModelSchema)` to create a new message. + */ +export const ChatSelectedModelSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 4); + +/** + * @generated from message liveagent.gateway.v1.ChatRuntimeControls + */ +export type ChatRuntimeControls = Message<"liveagent.gateway.v1.ChatRuntimeControls"> & { + /** + * @generated from field: bool thinking_enabled = 1; + */ + thinkingEnabled: boolean; + + /** + * @generated from field: bool native_web_search_enabled = 2; + */ + nativeWebSearchEnabled: boolean; + + /** + * @generated from field: string reasoning = 3; + */ + reasoning: string; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatRuntimeControls. + * Use `create(ChatRuntimeControlsSchema)` to create a new message. + */ +export const ChatRuntimeControlsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 5); + +/** + * @generated from message liveagent.gateway.v1.ChatUploadedFile + */ +export type ChatUploadedFile = Message<"liveagent.gateway.v1.ChatUploadedFile"> & { + /** + * @generated from field: string relative_path = 1; + */ + relativePath: string; + + /** + * @generated from field: string absolute_path = 2; + */ + absolutePath: string; + + /** + * @generated from field: string file_name = 3; + */ + fileName: string; + + /** + * @generated from field: string kind = 4; + */ + kind: string; + + /** + * @generated from field: int64 size_bytes = 5; + */ + sizeBytes: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatUploadedFile. + * Use `create(ChatUploadedFileSchema)` to create a new message. + */ +export const ChatUploadedFileSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 6); + +/** + * @generated from message liveagent.gateway.v1.UploadReadableFile + */ +export type UploadReadableFile = Message<"liveagent.gateway.v1.UploadReadableFile"> & { + /** + * @generated from field: string file_name = 1; + */ + fileName: string; + + /** + * @generated from field: string mime_type = 2; + */ + mimeType: string; + + /** + * @generated from field: bytes content = 3; + */ + content: Uint8Array; +}; + +/** + * Describes the message liveagent.gateway.v1.UploadReadableFile. + * Use `create(UploadReadableFileSchema)` to create a new message. + */ +export const UploadReadableFileSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 7); + +/** + * @generated from message liveagent.gateway.v1.UploadReadableFilesRequest + */ +export type UploadReadableFilesRequest = Message<"liveagent.gateway.v1.UploadReadableFilesRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: repeated liveagent.gateway.v1.UploadReadableFile files = 2; + */ + files: UploadReadableFile[]; +}; + +/** + * Describes the message liveagent.gateway.v1.UploadReadableFilesRequest. + * Use `create(UploadReadableFilesRequestSchema)` to create a new message. + */ +export const UploadReadableFilesRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 8); + +/** + * @generated from message liveagent.gateway.v1.UploadReadableFilesResponse + */ +export type UploadReadableFilesResponse = Message<"liveagent.gateway.v1.UploadReadableFilesResponse"> & { + /** + * @generated from field: repeated liveagent.gateway.v1.ChatUploadedFile files = 1; + */ + files: ChatUploadedFile[]; + + /** + * @generated from field: repeated string skipped = 2; + */ + skipped: string[]; +}; + +/** + * Describes the message liveagent.gateway.v1.UploadReadableFilesResponse. + * Use `create(UploadReadableFilesResponseSchema)` to create a new message. + */ +export const UploadReadableFilesResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 9); + +/** + * @generated from message liveagent.gateway.v1.UploadedImagePreviewRequest + */ +export type UploadedImagePreviewRequest = Message<"liveagent.gateway.v1.UploadedImagePreviewRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string absolute_path = 2; + */ + absolutePath: string; +}; + +/** + * Describes the message liveagent.gateway.v1.UploadedImagePreviewRequest. + * Use `create(UploadedImagePreviewRequestSchema)` to create a new message. + */ +export const UploadedImagePreviewRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 10); + +/** + * @generated from message liveagent.gateway.v1.UploadedImagePreviewResponse + */ +export type UploadedImagePreviewResponse = Message<"liveagent.gateway.v1.UploadedImagePreviewResponse"> & { + /** + * @generated from field: string mime_type = 1; + */ + mimeType: string; + + /** + * @generated from field: string data = 2; + */ + data: string; +}; + +/** + * Describes the message liveagent.gateway.v1.UploadedImagePreviewResponse. + * Use `create(UploadedImagePreviewResponseSchema)` to create a new message. + */ +export const UploadedImagePreviewResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 11); + +/** + * @generated from message liveagent.gateway.v1.TunnelSpec + */ +export type TunnelSpec = Message<"liveagent.gateway.v1.TunnelSpec"> & { + /** + * agent-generated, stable across restarts + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * last allocated slug; gateway honors when valid and unused + * + * @generated from field: string slug_hint = 2; + */ + slugHint: string; + + /** + * @generated from field: string name = 3; + */ + name: string; + + /** + * http://localhost:PORT[/base] + * + * @generated from field: string target_url = 4; + */ + targetUrl: string; + + /** + * unix seconds, 0 = never + * + * @generated from field: int64 expires_at = 5; + */ + expiresAt: bigint; + + /** + * @generated from field: string project_path_key = 6; + */ + projectPathKey: string; +}; + +/** + * Describes the message liveagent.gateway.v1.TunnelSpec. + * Use `create(TunnelSpecSchema)` to create a new message. + */ +export const TunnelSpecSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 12); + +/** + * @generated from message liveagent.gateway.v1.TunnelDesiredState + */ +export type TunnelDesiredState = Message<"liveagent.gateway.v1.TunnelDesiredState"> & { + /** + * @generated from field: repeated liveagent.gateway.v1.TunnelSpec tunnels = 1; + */ + tunnels: TunnelSpec[]; + + /** + * agent-side monotonic + * + * @generated from field: uint64 revision = 2; + */ + revision: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.TunnelDesiredState. + * Use `create(TunnelDesiredStateSchema)` to create a new message. + */ +export const TunnelDesiredStateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 13); + +/** + * @generated from message liveagent.gateway.v1.TunnelHealth + */ +export type TunnelHealth = Message<"liveagent.gateway.v1.TunnelHealth"> & { + /** + * "ok" | "failed" | "unknown" + * + * @generated from field: string status = 1; + */ + status: string; + + /** + * local layer only + * + * @generated from field: uint32 http_status = 2; + */ + httpStatus: number; + + /** + * @generated from field: string error = 3; + */ + error: string; + + /** + * @generated from field: int64 checked_at = 4; + */ + checkedAt: bigint; + + /** + * relay layer only + * + * @generated from field: uint32 rtt_ms = 5; + */ + rttMs: number; +}; + +/** + * Describes the message liveagent.gateway.v1.TunnelHealth. + * Use `create(TunnelHealthSchema)` to create a new message. + */ +export const TunnelHealthSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 14); + +/** + * @generated from message liveagent.gateway.v1.TunnelStatus + */ +export type TunnelStatus = Message<"liveagent.gateway.v1.TunnelStatus"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string slug = 2; + */ + slug: string; + + /** + * @generated from field: string name = 3; + */ + name: string; + + /** + * @generated from field: string target_url = 4; + */ + targetUrl: string; + + /** + * "/t/{slug}/"; clients compose the full URL + * + * @generated from field: string public_path = 5; + */ + publicPath: string; + + /** + * @generated from field: int64 created_at = 6; + */ + createdAt: bigint; + + /** + * @generated from field: int64 expires_at = 7; + */ + expiresAt: bigint; + + /** + * @generated from field: uint32 active_connections = 8; + */ + activeConnections: number; + + /** + * @generated from field: string project_path_key = 9; + */ + projectPathKey: string; + + /** + * agent -> local service reachability + * + * @generated from field: liveagent.gateway.v1.TunnelHealth local = 10; + */ + local?: TunnelHealth | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.TunnelStatus. + * Use `create(TunnelStatusSchema)` to create a new message. + */ +export const TunnelStatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 15); + +/** + * @generated from message liveagent.gateway.v1.TunnelStateSnapshot + */ +export type TunnelStateSnapshot = Message<"liveagent.gateway.v1.TunnelStateSnapshot"> & { + /** + * @generated from field: repeated liveagent.gateway.v1.TunnelStatus tunnels = 1; + */ + tunnels: TunnelStatus[]; + + /** + * gateway-side monotonic + * + * @generated from field: uint64 revision = 2; + */ + revision: bigint; + + /** + * @generated from field: bool agent_online = 3; + */ + agentOnline: boolean; + + /** + * gateway <-> agent frame path + * + * @generated from field: liveagent.gateway.v1.TunnelHealth relay = 4; + */ + relay?: TunnelHealth | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.TunnelStateSnapshot. + * Use `create(TunnelStateSnapshotSchema)` to create a new message. + */ +export const TunnelStateSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 16); + +/** + * @generated from message liveagent.gateway.v1.TunnelMutation + */ +export type TunnelMutation = Message<"liveagent.gateway.v1.TunnelMutation"> & { + /** + * "create" | "update" | "close" | "check" + * + * @generated from field: string action = 1; + */ + action: string; + + /** + * update/close/check + * + * @generated from field: string tunnel_id = 2; + */ + tunnelId: string; + + /** + * @generated from field: string target_url = 3; + */ + targetUrl: string; + + /** + * @generated from field: string name = 4; + */ + name: string; + + /** + * absent on update = keep current expiry + * + * @generated from field: optional uint32 ttl_seconds = 5; + */ + ttlSeconds?: number | undefined; + + /** + * @generated from field: string project_path_key = 6; + */ + projectPathKey: string; +}; + +/** + * Describes the message liveagent.gateway.v1.TunnelMutation. + * Use `create(TunnelMutationSchema)` to create a new message. + */ +export const TunnelMutationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 17); + +/** + * @generated from message liveagent.gateway.v1.TunnelMutationResult + */ +export type TunnelMutationResult = Message<"liveagent.gateway.v1.TunnelMutationResult"> & { + /** + * @generated from field: string tunnel_id = 1; + */ + tunnelId: string; + + /** + * "" = ok; invalid_target|limit_exceeded|not_found|invalid_ttl + * + * @generated from field: string error_code = 2; + */ + errorCode: string; + + /** + * @generated from field: string error_message = 3; + */ + errorMessage: string; +}; + +/** + * Describes the message liveagent.gateway.v1.TunnelMutationResult. + * Use `create(TunnelMutationResultSchema)` to create a new message. + */ +export const TunnelMutationResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 18); + +/** + * @generated from message liveagent.gateway.v1.TunnelProbeResult + */ +export type TunnelProbeResult = Message<"liveagent.gateway.v1.TunnelProbeResult"> & { + /** + * @generated from field: string tunnel_id = 1; + */ + tunnelId: string; + + /** + * @generated from field: liveagent.gateway.v1.TunnelHealth local = 2; + */ + local?: TunnelHealth | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.TunnelProbeResult. + * Use `create(TunnelProbeResultSchema)` to create a new message. + */ +export const TunnelProbeResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 19); + +/** + * @generated from message liveagent.gateway.v1.TunnelProbeReport + */ +export type TunnelProbeReport = Message<"liveagent.gateway.v1.TunnelProbeReport"> & { + /** + * @generated from field: repeated liveagent.gateway.v1.TunnelProbeResult results = 1; + */ + results: TunnelProbeResult[]; +}; + +/** + * Describes the message liveagent.gateway.v1.TunnelProbeReport. + * Use `create(TunnelProbeReportSchema)` to create a new message. + */ +export const TunnelProbeReportSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 20); + +/** + * @generated from message liveagent.gateway.v1.TunnelHeader + */ +export type TunnelHeader = Message<"liveagent.gateway.v1.TunnelHeader"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: string value = 2; + */ + value: string; +}; + +/** + * Describes the message liveagent.gateway.v1.TunnelHeader. + * Use `create(TunnelHeaderSchema)` to create a new message. + */ +export const TunnelHeaderSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 21); + +/** + * @generated from message liveagent.gateway.v1.TunnelFrame + */ +export type TunnelFrame = Message<"liveagent.gateway.v1.TunnelFrame"> & { + /** + * @generated from field: string stream_id = 1; + */ + streamId: string; + + /** + * @generated from field: liveagent.gateway.v1.TunnelFrameKind kind = 2; + */ + kind: TunnelFrameKind; + + /** + * set on HTTP_REQUEST_START and WS_DIAL only + * + * @generated from field: string target_url = 3; + */ + targetUrl: string; + + /** + * @generated from field: string method = 4; + */ + method: string; + + /** + * path+query relative to the target base + * + * @generated from field: string path = 5; + */ + path: string; + + /** + * @generated from field: repeated liveagent.gateway.v1.TunnelHeader headers = 6; + */ + headers: TunnelHeader[]; + + /** + * @generated from field: uint32 status = 7; + */ + status: number; + + /** + * @generated from field: bytes body = 8; + */ + body: Uint8Array; + + /** + * @generated from field: string error = 9; + */ + error: string; + + /** + * @generated from field: liveagent.gateway.v1.TunnelWsMessageType ws_message_type = 10; + */ + wsMessageType: TunnelWsMessageType; + + /** + * @generated from field: string ws_subprotocol = 11; + */ + wsSubprotocol: string; + + /** + * @generated from field: uint32 ws_close_code = 12; + */ + wsCloseCode: number; + + /** + * @generated from field: string ws_close_reason = 13; + */ + wsCloseReason: string; +}; + +/** + * Describes the message liveagent.gateway.v1.TunnelFrame. + * Use `create(TunnelFrameSchema)` to create a new message. + */ +export const TunnelFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 22); + +/** + * @generated from message liveagent.gateway.v1.WorkspaceWatchRequest + */ +export type WorkspaceWatchRequest = Message<"liveagent.gateway.v1.WorkspaceWatchRequest"> & { + /** + * full desired set; replaces the previous one + * + * @generated from field: repeated string workdirs = 1; + */ + workdirs: string[]; +}; + +/** + * Describes the message liveagent.gateway.v1.WorkspaceWatchRequest. + * Use `create(WorkspaceWatchRequestSchema)` to create a new message. + */ +export const WorkspaceWatchRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 23); + +/** + * @generated from message liveagent.gateway.v1.WorkspaceActivityEvent + */ +export type WorkspaceActivityEvent = Message<"liveagent.gateway.v1.WorkspaceActivityEvent"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * per-workdir monotonic within one agent process + * + * @generated from field: uint64 revision = 2; + */ + revision: bigint; + + /** + * working-tree content changed + * + * @generated from field: bool fs = 3; + */ + fs: boolean; + + /** + * git state (HEAD/refs/index/...) changed + * + * @generated from field: bool git = 4; + */ + git: boolean; + + /** + * relative to workdir, deduped, capped + * + * @generated from field: repeated string changed_paths = 5; + */ + changedPaths: string[]; + + /** + * changed_paths hit the cap or is unknown + * + * @generated from field: bool truncated = 6; + */ + truncated: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.WorkspaceActivityEvent. + * Use `create(WorkspaceActivityEventSchema)` to create a new message. + */ +export const WorkspaceActivityEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 24); + +/** + * @generated from message liveagent.gateway.v1.ManagedProcessRecord + */ +export type ManagedProcessRecord = Message<"liveagent.gateway.v1.ManagedProcessRecord"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string label = 2; + */ + label: string; + + /** + * @generated from field: string command = 3; + */ + command: string; + + /** + * @generated from field: string cwd = 4; + */ + cwd: string; + + /** + * @generated from field: string shell = 5; + */ + shell: string; + + /** + * @generated from field: uint32 pid = 6; + */ + pid: number; + + /** + * @generated from field: string log_path = 7; + */ + logPath: string; + + /** + * unix ms + * + * @generated from field: int64 started_at = 8; + */ + startedAt: bigint; + + /** + * unix ms + * + * @generated from field: optional int64 finished_at = 9; + */ + finishedAt?: bigint | undefined; + + /** + * absent for restored/pid-managed entries + * + * @generated from field: optional int32 exit_code = 10; + */ + exitCode?: number | undefined; + + /** + * @generated from field: bool running = 11; + */ + running: boolean; + + /** + * survives LiveAgent exit + * + * @generated from field: bool isolated = 12; + */ + isolated: boolean; + + /** + * recovered after restart; managed by pid + * + * @generated from field: bool restored = 13; + */ + restored: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.ManagedProcessRecord. + * Use `create(ManagedProcessRecordSchema)` to create a new message. + */ +export const ManagedProcessRecordSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 25); + +/** + * @generated from message liveagent.gateway.v1.ManagedProcessSnapshot + */ +export type ManagedProcessSnapshot = Message<"liveagent.gateway.v1.ManagedProcessSnapshot"> & { + /** + * @generated from field: repeated liveagent.gateway.v1.ManagedProcessRecord processes = 1; + */ + processes: ManagedProcessRecord[]; + + /** + * agent-side monotonic, restart-safe + * + * @generated from field: uint64 revision = 2; + */ + revision: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.ManagedProcessSnapshot. + * Use `create(ManagedProcessSnapshotSchema)` to create a new message. + */ +export const ManagedProcessSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 26); + +/** + * @generated from message liveagent.gateway.v1.ManagedProcessRequest + */ +export type ManagedProcessRequest = Message<"liveagent.gateway.v1.ManagedProcessRequest"> & { + /** + * "snapshot" | "stop" | "read_log" | "clear" + * + * @generated from field: string action = 1; + */ + action: string; + + /** + * stop/read_log; optional for clear + * + * @generated from field: string process_id = 2; + */ + processId: string; + + /** + * read_log only; 0 = default + * + * @generated from field: uint32 max_bytes = 3; + */ + maxBytes: number; +}; + +/** + * Describes the message liveagent.gateway.v1.ManagedProcessRequest. + * Use `create(ManagedProcessRequestSchema)` to create a new message. + */ +export const ManagedProcessRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 27); + +/** + * @generated from message liveagent.gateway.v1.ManagedProcessResponse + */ +export type ManagedProcessResponse = Message<"liveagent.gateway.v1.ManagedProcessResponse"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * set for snapshot/stop/clear + * + * @generated from field: liveagent.gateway.v1.ManagedProcessSnapshot snapshot = 2; + */ + snapshot?: ManagedProcessSnapshot | undefined; + + /** + * read_log only + * + * @generated from field: string log_content = 3; + */ + logContent: string; + + /** + * read_log only + * + * @generated from field: string log_path = 4; + */ + logPath: string; + + /** + * read_log only + * + * @generated from field: bool log_truncated = 5; + */ + logTruncated: boolean; + + /** + * stop only + * + * @generated from field: bool stopped = 6; + */ + stopped: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.ManagedProcessResponse. + * Use `create(ManagedProcessResponseSchema)` to create a new message. + */ +export const ManagedProcessResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 28); + +/** + * @generated from message liveagent.gateway.v1.MemoryManageRequest + */ +export type MemoryManageRequest = Message<"liveagent.gateway.v1.MemoryManageRequest"> & { + /** + * @generated from field: string command = 1; + */ + command: string; + + /** + * @generated from field: string args_json = 2; + */ + argsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.MemoryManageRequest. + * Use `create(MemoryManageRequestSchema)` to create a new message. + */ +export const MemoryManageRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 29); + +/** + * @generated from message liveagent.gateway.v1.MemoryManageResponse + */ +export type MemoryManageResponse = Message<"liveagent.gateway.v1.MemoryManageResponse"> & { + /** + * @generated from field: string result_json = 1; + */ + resultJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.MemoryManageResponse. + * Use `create(MemoryManageResponseSchema)` to create a new message. + */ +export const MemoryManageResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 30); + +/** + * @generated from message liveagent.gateway.v1.TerminalRequest + */ +export type TerminalRequest = Message<"liveagent.gateway.v1.TerminalRequest"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string session_id = 2; + */ + sessionId: string; + + /** + * @generated from field: string project_path_key = 3; + */ + projectPathKey: string; + + /** + * @generated from field: string cwd = 4; + */ + cwd: string; + + /** + * @generated from field: string shell = 5; + */ + shell: string; + + /** + * @generated from field: string title = 6; + */ + title: string; + + /** + * @generated from field: string data = 7; + */ + data: string; + + /** + * @generated from field: uint32 cols = 8; + */ + cols: number; + + /** + * @generated from field: uint32 rows = 9; + */ + rows: number; + + /** + * @generated from field: uint32 max_bytes = 10; + */ + maxBytes: number; + + /** + * @generated from field: string ssh_host_id = 11; + */ + sshHostId: string; + + /** + * @generated from field: string prompt_id = 12; + */ + promptId: string; + + /** + * @generated from field: string prompt_answer = 13; + */ + promptAnswer: string; + + /** + * @generated from field: bool trust_host_key = 14; + */ + trustHostKey: boolean; + + /** + * @generated from field: bool sftp_enabled = 15; + */ + sftpEnabled: boolean; + + /** + * @generated from field: string tab_id = 16; + */ + tabId: string; + + /** + * @generated from field: string tab_kind = 17; + */ + tabKind: string; +}; + +/** + * Describes the message liveagent.gateway.v1.TerminalRequest. + * Use `create(TerminalRequestSchema)` to create a new message. + */ +export const TerminalRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 31); + +/** + * @generated from message liveagent.gateway.v1.TerminalSession + */ +export type TerminalSession = Message<"liveagent.gateway.v1.TerminalSession"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string project_path_key = 2; + */ + projectPathKey: string; + + /** + * @generated from field: string cwd = 3; + */ + cwd: string; + + /** + * @generated from field: string shell = 4; + */ + shell: string; + + /** + * @generated from field: string title = 5; + */ + title: string; + + /** + * @generated from field: uint32 pid = 6; + */ + pid: number; + + /** + * @generated from field: uint32 cols = 7; + */ + cols: number; + + /** + * @generated from field: uint32 rows = 8; + */ + rows: number; + + /** + * @generated from field: uint64 created_at = 9; + */ + createdAt: bigint; + + /** + * @generated from field: uint64 updated_at = 10; + */ + updatedAt: bigint; + + /** + * @generated from field: uint64 finished_at = 11; + */ + finishedAt: bigint; + + /** + * @generated from field: int32 exit_code = 12; + */ + exitCode: number; + + /** + * @generated from field: bool running = 13; + */ + running: boolean; + + /** + * @generated from field: string kind = 14; + */ + kind: string; + + /** + * @generated from field: liveagent.gateway.v1.TerminalSshMetadata ssh = 15; + */ + ssh?: TerminalSshMetadata | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.TerminalSession. + * Use `create(TerminalSessionSchema)` to create a new message. + */ +export const TerminalSessionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 32); + +/** + * @generated from message liveagent.gateway.v1.TerminalSshMetadata + */ +export type TerminalSshMetadata = Message<"liveagent.gateway.v1.TerminalSshMetadata"> & { + /** + * @generated from field: string host_id = 1; + */ + hostId: string; + + /** + * @generated from field: string host_name = 2; + */ + hostName: string; + + /** + * @generated from field: string username = 3; + */ + username: string; + + /** + * @generated from field: string host = 4; + */ + host: string; + + /** + * @generated from field: uint32 port = 5; + */ + port: number; + + /** + * @generated from field: string auth_type = 6; + */ + authType: string; + + /** + * @generated from field: string status = 7; + */ + status: string; + + /** + * @generated from field: uint32 reconnect_attempt = 8; + */ + reconnectAttempt: number; + + /** + * @generated from field: uint32 reconnect_max_attempts = 9; + */ + reconnectMaxAttempts: number; + + /** + * @generated from field: bool sftp_enabled = 10; + */ + sftpEnabled: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.TerminalSshMetadata. + * Use `create(TerminalSshMetadataSchema)` to create a new message. + */ +export const TerminalSshMetadataSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 33); + +/** + * @generated from message liveagent.gateway.v1.SftpRequest + */ +export type SftpRequest = Message<"liveagent.gateway.v1.SftpRequest"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string session_id = 2; + */ + sessionId: string; + + /** + * @generated from field: string project_path_key = 3; + */ + projectPathKey: string; + + /** + * @generated from field: string workdir = 4; + */ + workdir: string; + + /** + * @generated from field: string local_path = 5; + */ + localPath: string; + + /** + * @generated from field: string remote_path = 6; + */ + remotePath: string; + + /** + * @generated from field: string from_path = 7; + */ + fromPath: string; + + /** + * @generated from field: string to_path = 8; + */ + toPath: string; + + /** + * @generated from field: string direction = 9; + */ + direction: string; + + /** + * @generated from field: string target_path = 10; + */ + targetPath: string; + + /** + * @generated from field: bool recursive = 11; + */ + recursive: boolean; + + /** + * @generated from field: bool overwrite = 12; + */ + overwrite: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.SftpRequest. + * Use `create(SftpRequestSchema)` to create a new message. + */ +export const SftpRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 34); + +/** + * @generated from message liveagent.gateway.v1.SftpEntry + */ +export type SftpEntry = Message<"liveagent.gateway.v1.SftpEntry"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: string kind = 3; + */ + kind: string; + + /** + * @generated from field: uint64 size_bytes = 4; + */ + sizeBytes: bigint; + + /** + * @generated from field: uint64 mtime = 5; + */ + mtime: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.SftpEntry. + * Use `create(SftpEntrySchema)` to create a new message. + */ +export const SftpEntrySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 35); + +/** + * @generated from message liveagent.gateway.v1.SftpTransfer + */ +export type SftpTransfer = Message<"liveagent.gateway.v1.SftpTransfer"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string session_id = 2; + */ + sessionId: string; + + /** + * @generated from field: string direction = 3; + */ + direction: string; + + /** + * @generated from field: string status = 4; + */ + status: string; + + /** + * @generated from field: string source_path = 5; + */ + sourcePath: string; + + /** + * @generated from field: string target_path = 6; + */ + targetPath: string; + + /** + * @generated from field: string current_path = 7; + */ + currentPath: string; + + /** + * @generated from field: uint64 bytes_done = 8; + */ + bytesDone: bigint; + + /** + * @generated from field: uint64 bytes_total = 9; + */ + bytesTotal: bigint; + + /** + * @generated from field: uint32 files_done = 10; + */ + filesDone: number; + + /** + * @generated from field: uint32 files_total = 11; + */ + filesTotal: number; + + /** + * @generated from field: string error = 12; + */ + error: string; +}; + +/** + * Describes the message liveagent.gateway.v1.SftpTransfer. + * Use `create(SftpTransferSchema)` to create a new message. + */ +export const SftpTransferSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 36); + +/** + * @generated from message liveagent.gateway.v1.SftpResponse + */ +export type SftpResponse = Message<"liveagent.gateway.v1.SftpResponse"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: repeated liveagent.gateway.v1.SftpEntry entries = 3; + */ + entries: SftpEntry[]; + + /** + * @generated from field: liveagent.gateway.v1.SftpEntry entry = 4; + */ + entry?: SftpEntry | undefined; + + /** + * @generated from field: bool exists = 5; + */ + exists: boolean; + + /** + * @generated from field: liveagent.gateway.v1.SftpTransfer transfer = 6; + */ + transfer?: SftpTransfer | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.SftpResponse. + * Use `create(SftpResponseSchema)` to create a new message. + */ +export const SftpResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 37); + +/** + * @generated from message liveagent.gateway.v1.SftpEvent + */ +export type SftpEvent = Message<"liveagent.gateway.v1.SftpEvent"> & { + /** + * @generated from field: string kind = 1; + */ + kind: string; + + /** + * @generated from field: liveagent.gateway.v1.SftpTransfer transfer = 2; + */ + transfer?: SftpTransfer | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.SftpEvent. + * Use `create(SftpEventSchema)` to create a new message. + */ +export const SftpEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 38); + +/** + * @generated from message liveagent.gateway.v1.TerminalSshPrompt + */ +export type TerminalSshPrompt = Message<"liveagent.gateway.v1.TerminalSshPrompt"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; + + /** + * @generated from field: string host_id = 3; + */ + hostId: string; + + /** + * @generated from field: string host_name = 4; + */ + hostName: string; + + /** + * @generated from field: string host = 5; + */ + host: string; + + /** + * @generated from field: uint32 port = 6; + */ + port: number; + + /** + * @generated from field: string message = 7; + */ + message: string; + + /** + * @generated from field: string fingerprint_sha256 = 8; + */ + fingerprintSha256: string; + + /** + * @generated from field: string key_type = 9; + */ + keyType: string; + + /** + * @generated from field: bool answer_echo = 10; + */ + answerEcho: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.TerminalSshPrompt. + * Use `create(TerminalSshPromptSchema)` to create a new message. + */ +export const TerminalSshPromptSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 39); + +/** + * @generated from message liveagent.gateway.v1.TerminalShellOption + */ +export type TerminalShellOption = Message<"liveagent.gateway.v1.TerminalShellOption"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string label = 2; + */ + label: string; + + /** + * @generated from field: string command = 3; + */ + command: string; +}; + +/** + * Describes the message liveagent.gateway.v1.TerminalShellOption. + * Use `create(TerminalShellOptionSchema)` to create a new message. + */ +export const TerminalShellOptionSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 40); + +/** + * @generated from message liveagent.gateway.v1.TerminalSshTab + */ +export type TerminalSshTab = Message<"liveagent.gateway.v1.TerminalSshTab"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string session_id = 2; + */ + sessionId: string; + + /** + * @generated from field: string project_path_key = 3; + */ + projectPathKey: string; + + /** + * @generated from field: string kind = 4; + */ + kind: string; + + /** + * @generated from field: uint64 created_at = 5; + */ + createdAt: bigint; + + /** + * @generated from field: uint64 updated_at = 6; + */ + updatedAt: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.TerminalSshTab. + * Use `create(TerminalSshTabSchema)` to create a new message. + */ +export const TerminalSshTabSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 41); + +/** + * @generated from message liveagent.gateway.v1.TerminalSshTabsSnapshot + */ +export type TerminalSshTabsSnapshot = Message<"liveagent.gateway.v1.TerminalSshTabsSnapshot"> & { + /** + * @generated from field: string project_path_key = 1; + */ + projectPathKey: string; + + /** + * @generated from field: repeated liveagent.gateway.v1.TerminalSshTab tabs = 2; + */ + tabs: TerminalSshTab[]; + + /** + * @generated from field: uint64 revision = 4; + */ + revision: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.TerminalSshTabsSnapshot. + * Use `create(TerminalSshTabsSnapshotSchema)` to create a new message. + */ +export const TerminalSshTabsSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 42); + +/** + * @generated from message liveagent.gateway.v1.TerminalResponse + */ +export type TerminalResponse = Message<"liveagent.gateway.v1.TerminalResponse"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: repeated liveagent.gateway.v1.TerminalSession sessions = 2; + */ + sessions: TerminalSession[]; + + /** + * @generated from field: liveagent.gateway.v1.TerminalSession session = 3; + */ + session?: TerminalSession | undefined; + + /** + * @generated from field: bytes output = 4; + */ + output: Uint8Array; + + /** + * @generated from field: bool truncated = 5; + */ + truncated: boolean; + + /** + * @generated from field: repeated liveagent.gateway.v1.TerminalShellOption shell_options = 6; + */ + shellOptions: TerminalShellOption[]; + + /** + * @generated from field: string default_shell = 7; + */ + defaultShell: string; + + /** + * @generated from field: uint64 output_start_offset = 8; + */ + outputStartOffset: bigint; + + /** + * @generated from field: uint64 output_end_offset = 9; + */ + outputEndOffset: bigint; + + /** + * @generated from field: liveagent.gateway.v1.TerminalSshPrompt ssh_prompt = 10; + */ + sshPrompt?: TerminalSshPrompt | undefined; + + /** + * @generated from field: uint32 latency_ms = 11; + */ + latencyMs: number; + + /** + * @generated from field: liveagent.gateway.v1.TerminalSshTabsSnapshot ssh_tabs = 12; + */ + sshTabs?: TerminalSshTabsSnapshot | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.TerminalResponse. + * Use `create(TerminalResponseSchema)` to create a new message. + */ +export const TerminalResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 43); + +/** + * @generated from message liveagent.gateway.v1.TerminalEvent + */ +export type TerminalEvent = Message<"liveagent.gateway.v1.TerminalEvent"> & { + /** + * @generated from field: string kind = 1; + */ + kind: string; + + /** + * @generated from field: string session_id = 2; + */ + sessionId: string; + + /** + * @generated from field: string project_path_key = 3; + */ + projectPathKey: string; + + /** + * @generated from field: liveagent.gateway.v1.TerminalSession session = 4; + */ + session?: TerminalSession | undefined; + + /** + * @generated from field: bytes data = 5; + */ + data: Uint8Array; + + /** + * @generated from field: uint64 output_start_offset = 6; + */ + outputStartOffset: bigint; + + /** + * @generated from field: uint64 output_end_offset = 7; + */ + outputEndOffset: bigint; + + /** + * @generated from field: liveagent.gateway.v1.TerminalSshTabsSnapshot ssh_tabs = 8; + */ + sshTabs?: TerminalSshTabsSnapshot | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.TerminalEvent. + * Use `create(TerminalEventSchema)` to create a new message. + */ +export const TerminalEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 44); + +/** + * @generated from message liveagent.gateway.v1.TerminalStreamFrame + */ +export type TerminalStreamFrame = Message<"liveagent.gateway.v1.TerminalStreamFrame"> & { + /** + * @generated from field: string kind = 1; + */ + kind: string; + + /** + * @generated from field: string stream_id = 2; + */ + streamId: string; + + /** + * @generated from field: string session_id = 3; + */ + sessionId: string; + + /** + * @generated from field: string project_path_key = 4; + */ + projectPathKey: string; + + /** + * @generated from field: uint64 seq = 5; + */ + seq: bigint; + + /** + * @generated from field: uint64 start_offset = 6; + */ + startOffset: bigint; + + /** + * @generated from field: uint64 end_offset = 7; + */ + endOffset: bigint; + + /** + * @generated from field: uint32 cols = 8; + */ + cols: number; + + /** + * @generated from field: uint32 rows = 9; + */ + rows: number; + + /** + * @generated from field: uint32 max_bytes = 10; + */ + maxBytes: number; + + /** + * @generated from field: bool truncated = 11; + */ + truncated: boolean; + + /** + * @generated from field: string error = 12; + */ + error: string; + + /** + * @generated from field: liveagent.gateway.v1.TerminalSession session = 13; + */ + session?: TerminalSession | undefined; + + /** + * @generated from field: bytes data = 14; + */ + data: Uint8Array; +}; + +/** + * Describes the message liveagent.gateway.v1.TerminalStreamFrame. + * Use `create(TerminalStreamFrameSchema)` to create a new message. + */ +export const TerminalStreamFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 45); + +/** + * @generated from message liveagent.gateway.v1.GitRequest + */ +export type GitRequest = Message<"liveagent.gateway.v1.GitRequest"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string workdir = 2; + */ + workdir: string; + + /** + * @generated from field: string args_json = 3; + */ + argsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.GitRequest. + * Use `create(GitRequestSchema)` to create a new message. + */ +export const GitRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 46); + +/** + * @generated from message liveagent.gateway.v1.GitResponse + */ +export type GitResponse = Message<"liveagent.gateway.v1.GitResponse"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string result_json = 2; + */ + resultJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.GitResponse. + * Use `create(GitResponseSchema)` to create a new message. + */ +export const GitResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 47); + +/** + * @generated from message liveagent.gateway.v1.ChatRequest + */ +export type ChatRequest = Message<"liveagent.gateway.v1.ChatRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string message = 2; + */ + message: string; + + /** + * @generated from field: liveagent.gateway.v1.ChatSelectedModel selected_model = 3; + */ + selectedModel?: ChatSelectedModel | undefined; + + /** + * @generated from field: string execution_mode = 4; + */ + executionMode: string; + + /** + * @generated from field: string workdir = 5; + */ + workdir: string; + + /** + * @generated from field: repeated string selected_system_tools = 6; + */ + selectedSystemTools: string[]; + + /** + * @generated from field: repeated liveagent.gateway.v1.ChatUploadedFile uploaded_files = 7; + */ + uploadedFiles: ChatUploadedFile[]; + + /** + * @generated from field: string client_request_id = 8; + */ + clientRequestId: string; + + /** + * @generated from field: liveagent.gateway.v1.ChatRuntimeControls runtime_controls = 9; + */ + runtimeControls?: ChatRuntimeControls | undefined; + + /** + * @generated from field: string queue_policy = 10; + */ + queuePolicy: string; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatRequest. + * Use `create(ChatRequestSchema)` to create a new message. + */ +export const ChatRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 48); + +/** + * @generated from message liveagent.gateway.v1.ChatMessageRef + */ +export type ChatMessageRef = Message<"liveagent.gateway.v1.ChatMessageRef"> & { + /** + * @generated from field: int32 segment_index = 1; + */ + segmentIndex: number; + + /** + * @generated from field: int32 message_index = 2; + */ + messageIndex: number; + + /** + * @generated from field: string segment_id = 3; + */ + segmentId: string; + + /** + * @generated from field: string message_id = 4; + */ + messageId: string; + + /** + * @generated from field: string role = 5; + */ + role: string; + + /** + * @generated from field: string content_hash = 6; + */ + contentHash: string; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatMessageRef. + * Use `create(ChatMessageRefSchema)` to create a new message. + */ +export const ChatMessageRefSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 49); + +/** + * @generated from message liveagent.gateway.v1.CancelChatRequest + */ +export type CancelChatRequest = Message<"liveagent.gateway.v1.CancelChatRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * 可选运行 id 提示:v2 浏览器链路用它消除同会话并发运行的歧义;桌面端可忽略。 + * + * @generated from field: string run_id = 2; + */ + runId: string; +}; + +/** + * Describes the message liveagent.gateway.v1.CancelChatRequest. + * Use `create(CancelChatRequestSchema)` to create a new message. + */ +export const CancelChatRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 50); + +/** + * @generated from message liveagent.gateway.v1.ChatCommandRequest + */ +export type ChatCommandRequest = Message<"liveagent.gateway.v1.ChatCommandRequest"> & { + /** + * @generated from field: string type = 1; + */ + type: string; + + /** + * @generated from field: liveagent.gateway.v1.ChatRequest request = 2; + */ + request?: ChatRequest | undefined; + + /** + * @generated from field: liveagent.gateway.v1.ChatMessageRef base_message_ref = 3; + */ + baseMessageRef?: ChatMessageRef | undefined; + + /** + * @generated from field: liveagent.gateway.v1.CancelChatRequest cancel = 4; + */ + cancel?: CancelChatRequest | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatCommandRequest. + * Use `create(ChatCommandRequestSchema)` to create a new message. + */ +export const ChatCommandRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 51); + +/** + * @generated from message liveagent.gateway.v1.ChatQueueRequest + */ +export type ChatQueueRequest = Message<"liveagent.gateway.v1.ChatQueueRequest"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: string item_id = 3; + */ + itemId: string; + + /** + * @generated from field: string direction = 4; + */ + direction: string; + + /** + * @generated from field: uint64 revision = 5; + */ + revision: bigint; + + /** + * @generated from field: string draft_json = 6; + */ + draftJson: string; + + /** + * @generated from field: string uploaded_files_json = 7; + */ + uploadedFilesJson: string; + + /** + * @generated from field: string request_json = 8; + */ + requestJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatQueueRequest. + * Use `create(ChatQueueRequestSchema)` to create a new message. + */ +export const ChatQueueRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 52); + +/** + * @generated from message liveagent.gateway.v1.ChatQueueResponse + */ +export type ChatQueueResponse = Message<"liveagent.gateway.v1.ChatQueueResponse"> & { + /** + * @generated from field: bool accepted = 1; + */ + accepted: boolean; + + /** + * @generated from field: string message = 2; + */ + message: string; + + /** + * @generated from field: string snapshot_json = 3; + */ + snapshotJson: string; + + /** + * @generated from field: string item_json = 4; + */ + itemJson: string; + + /** + * @generated from field: string error_code = 5; + */ + errorCode: string; + + /** + * @generated from field: uint64 revision = 6; + */ + revision: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatQueueResponse. + * Use `create(ChatQueueResponseSchema)` to create a new message. + */ +export const ChatQueueResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 53); + +/** + * @generated from message liveagent.gateway.v1.ChatQueueEvent + */ +export type ChatQueueEvent = Message<"liveagent.gateway.v1.ChatQueueEvent"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string snapshot_json = 2; + */ + snapshotJson: string; + + /** + * @generated from field: uint64 revision = 3; + */ + revision: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatQueueEvent. + * Use `create(ChatQueueEventSchema)` to create a new message. + */ +export const ChatQueueEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 54); + +/** + * @generated from message liveagent.gateway.v1.ChatEvent + */ +export type ChatEvent = Message<"liveagent.gateway.v1.ChatEvent"> & { + /** + * @generated from field: liveagent.gateway.v1.ChatEvent.ChatEventType type = 1; + */ + type: ChatEvent_ChatEventType; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: string data = 3; + */ + data: string; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatEvent. + * Use `create(ChatEventSchema)` to create a new message. + */ +export const ChatEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 55); + +/** + * @generated from enum liveagent.gateway.v1.ChatEvent.ChatEventType + */ +export enum ChatEvent_ChatEventType { + /** + * @generated from enum value: TOKEN = 0; + */ + TOKEN = 0, + + /** + * @generated from enum value: THINKING = 1; + */ + THINKING = 1, + + /** + * @generated from enum value: TOOL_CALL = 2; + */ + TOOL_CALL = 2, + + /** + * @generated from enum value: TOOL_RESULT = 3; + */ + TOOL_RESULT = 3, + + /** + * @generated from enum value: DONE = 4; + */ + DONE = 4, + + /** + * @generated from enum value: ERROR = 5; + */ + ERROR = 5, + + /** + * @generated from enum value: TOOL_STATUS = 6; + */ + TOOL_STATUS = 6, + + /** + * @generated from enum value: HOSTED_SEARCH = 7; + */ + HOSTED_SEARCH = 7, + + /** + * @generated from enum value: USER_MESSAGE = 8; + */ + USER_MESSAGE = 8, +} + +/** + * Describes the enum liveagent.gateway.v1.ChatEvent.ChatEventType. + */ +export const ChatEvent_ChatEventTypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_proto_v1_gateway, 55, 0); + +/** + * @generated from message liveagent.gateway.v1.ChatControlEvent + */ +export type ChatControlEvent = Message<"liveagent.gateway.v1.ChatControlEvent"> & { + /** + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * @generated from field: string client_request_id = 2; + */ + clientRequestId: string; + + /** + * @generated from field: string conversation_id = 3; + */ + conversationId: string; + + /** + * @generated from field: int64 run_epoch = 4; + */ + runEpoch: bigint; + + /** + * @generated from field: string type = 5; + */ + type: string; + + /** + * @generated from field: string state = 6; + */ + state: string; + + /** + * @generated from field: string error_code = 7; + */ + errorCode: string; + + /** + * @generated from field: string message = 8; + */ + message: string; + + /** + * @generated from field: int64 seq = 9; + */ + seq: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatControlEvent. + * Use `create(ChatControlEventSchema)` to create a new message. + */ +export const ChatControlEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 56); + +/** + * @generated from message liveagent.gateway.v1.ChatRuntimeSnapshot + */ +export type ChatRuntimeSnapshot = Message<"liveagent.gateway.v1.ChatRuntimeSnapshot"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string run_id = 2; + */ + runId: string; + + /** + * @generated from field: string client_request_id = 3; + */ + clientRequestId: string; + + /** + * @generated from field: string worker_id = 4; + */ + workerId: string; + + /** + * @generated from field: string state = 5; + */ + state: string; + + /** + * @generated from field: string cwd = 6; + */ + cwd: string; + + /** + * @generated from field: int64 updated_at = 7; + */ + updatedAt: bigint; + + /** + * @generated from field: int64 revision = 8; + */ + revision: bigint; + + /** + * @generated from field: string entries_json = 9; + */ + entriesJson: string; + + /** + * @generated from field: string tool_status = 10; + */ + toolStatus: string; + + /** + * @generated from field: bool tool_status_is_compaction = 11; + */ + toolStatusIsCompaction: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatRuntimeSnapshot. + * Use `create(ChatRuntimeSnapshotSchema)` to create a new message. + */ +export const ChatRuntimeSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 57); + +/** + * @generated from message liveagent.gateway.v1.RuntimeStatusEvent + */ +export type RuntimeStatusEvent = Message<"liveagent.gateway.v1.RuntimeStatusEvent"> & { + /** + * @generated from field: string worker_id = 1; + */ + workerId: string; + + /** + * @generated from field: string state = 2; + */ + state: string; + + /** + * @generated from field: bool visible = 3; + */ + visible: boolean; + + /** + * @generated from field: uint32 active_run_count = 4; + */ + activeRunCount: number; + + /** + * @generated from field: int64 timestamp = 5; + */ + timestamp: bigint; + + /** + * @generated from field: repeated liveagent.gateway.v1.ChatRunReport active_runs = 6; + */ + activeRuns: ChatRunReport[]; + + /** + * @generated from field: repeated liveagent.gateway.v1.ChatRunReport finished_runs = 7; + */ + finishedRuns: ChatRunReport[]; +}; + +/** + * Describes the message liveagent.gateway.v1.RuntimeStatusEvent. + * Use `create(RuntimeStatusEventSchema)` to create a new message. + */ +export const RuntimeStatusEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 58); + +/** + * @generated from message liveagent.gateway.v1.ChatRunReport + */ +export type ChatRunReport = Message<"liveagent.gateway.v1.ChatRunReport"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: string state = 3; + */ + state: string; + + /** + * @generated from field: string error_code = 4; + */ + errorCode: string; + + /** + * @generated from field: string message = 5; + */ + message: string; + + /** + * @generated from field: int64 updated_at = 6; + */ + updatedAt: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.ChatRunReport. + * Use `create(ChatRunReportSchema)` to create a new message. + */ +export const ChatRunReportSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 59); + +/** + * @generated from message liveagent.gateway.v1.CronManageRequest + */ +export type CronManageRequest = Message<"liveagent.gateway.v1.CronManageRequest"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string task_id = 2; + */ + taskId: string; + + /** + * @generated from field: string task_json = 3; + */ + taskJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.CronManageRequest. + * Use `create(CronManageRequestSchema)` to create a new message. + */ +export const CronManageRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 60); + +/** + * @generated from message liveagent.gateway.v1.CronManageResponse + */ +export type CronManageResponse = Message<"liveagent.gateway.v1.CronManageResponse"> & { + /** + * @generated from field: string action = 1; + */ + action: string; + + /** + * @generated from field: string result_json = 2; + */ + resultJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.CronManageResponse. + * Use `create(CronManageResponseSchema)` to create a new message. + */ +export const CronManageResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 61); + +/** + * @generated from message liveagent.gateway.v1.HistoryListRequest + */ +export type HistoryListRequest = Message<"liveagent.gateway.v1.HistoryListRequest"> & { + /** + * @generated from field: int32 page = 1; + */ + page: number; + + /** + * @generated from field: int32 page_size = 2; + */ + pageSize: number; + + /** + * @generated from field: string cwd = 3; + */ + cwd: string; + + /** + * @generated from field: bool cwd_empty = 4; + */ + cwdEmpty: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryListRequest. + * Use `create(HistoryListRequestSchema)` to create a new message. + */ +export const HistoryListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 62); + +/** + * @generated from message liveagent.gateway.v1.HistoryListResponse + */ +export type HistoryListResponse = Message<"liveagent.gateway.v1.HistoryListResponse"> & { + /** + * @generated from field: repeated liveagent.gateway.v1.ConversationSummary conversations = 1; + */ + conversations: ConversationSummary[]; + + /** + * @generated from field: int32 total_count = 2; + */ + totalCount: number; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryListResponse. + * Use `create(HistoryListResponseSchema)` to create a new message. + */ +export const HistoryListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 63); + +/** + * @generated from message liveagent.gateway.v1.ConversationSummary + */ +export type ConversationSummary = Message<"liveagent.gateway.v1.ConversationSummary"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string title = 2; + */ + title: string; + + /** + * @generated from field: int64 created_at = 3; + */ + createdAt: bigint; + + /** + * @generated from field: int64 updated_at = 4; + */ + updatedAt: bigint; + + /** + * @generated from field: int32 message_count = 5; + */ + messageCount: number; + + /** + * @generated from field: string provider_id = 6; + */ + providerId: string; + + /** + * @generated from field: string model = 7; + */ + model: string; + + /** + * @generated from field: string session_id = 8; + */ + sessionId: string; + + /** + * @generated from field: string cwd = 9; + */ + cwd: string; + + /** + * @generated from field: bool is_pinned = 10; + */ + isPinned: boolean; + + /** + * @generated from field: int64 pinned_at = 11; + */ + pinnedAt: bigint; + + /** + * @generated from field: bool is_shared = 12; + */ + isShared: boolean; + + /** + * @generated from field: string selected_model_json = 13; + */ + selectedModelJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.ConversationSummary. + * Use `create(ConversationSummarySchema)` to create a new message. + */ +export const ConversationSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 64); + +/** + * @generated from message liveagent.gateway.v1.HistoryGetRequest + */ +export type HistoryGetRequest = Message<"liveagent.gateway.v1.HistoryGetRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: int32 max_messages = 2; + */ + maxMessages: number; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryGetRequest. + * Use `create(HistoryGetRequestSchema)` to create a new message. + */ +export const HistoryGetRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 65); + +/** + * @generated from message liveagent.gateway.v1.HistoryGetResponse + */ +export type HistoryGetResponse = Message<"liveagent.gateway.v1.HistoryGetResponse"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string messages_json = 2; + */ + messagesJson: string; + + /** + * @generated from field: int32 total_message_count = 3; + */ + totalMessageCount: number; + + /** + * @generated from field: int32 returned_message_count = 4; + */ + returnedMessageCount: number; + + /** + * @generated from field: bool has_more = 5; + */ + hasMore: boolean; + + /** + * @generated from field: liveagent.gateway.v1.ConversationSummary conversation = 6; + */ + conversation?: ConversationSummary | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryGetResponse. + * Use `create(HistoryGetResponseSchema)` to create a new message. + */ +export const HistoryGetResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 66); + +/** + * @generated from message liveagent.gateway.v1.HistoryPrefixRequest + */ +export type HistoryPrefixRequest = Message<"liveagent.gateway.v1.HistoryPrefixRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: int32 max_messages = 2; + */ + maxMessages: number; + + /** + * @generated from field: liveagent.gateway.v1.ChatMessageRef base_message_ref = 3; + */ + baseMessageRef?: ChatMessageRef | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryPrefixRequest. + * Use `create(HistoryPrefixRequestSchema)` to create a new message. + */ +export const HistoryPrefixRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 67); + +/** + * @generated from message liveagent.gateway.v1.HistoryPrefixResponse + */ +export type HistoryPrefixResponse = Message<"liveagent.gateway.v1.HistoryPrefixResponse"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string messages_json = 2; + */ + messagesJson: string; + + /** + * @generated from field: int32 total_message_count = 3; + */ + totalMessageCount: number; + + /** + * @generated from field: int32 returned_message_count = 4; + */ + returnedMessageCount: number; + + /** + * @generated from field: bool has_more = 5; + */ + hasMore: boolean; + + /** + * @generated from field: liveagent.gateway.v1.ConversationSummary conversation = 6; + */ + conversation?: ConversationSummary | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryPrefixResponse. + * Use `create(HistoryPrefixResponseSchema)` to create a new message. + */ +export const HistoryPrefixResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 68); + +/** + * @generated from message liveagent.gateway.v1.HistoryRenameRequest + */ +export type HistoryRenameRequest = Message<"liveagent.gateway.v1.HistoryRenameRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string title = 2; + */ + title: string; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryRenameRequest. + * Use `create(HistoryRenameRequestSchema)` to create a new message. + */ +export const HistoryRenameRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 69); + +/** + * @generated from message liveagent.gateway.v1.HistoryRenameResponse + */ +export type HistoryRenameResponse = Message<"liveagent.gateway.v1.HistoryRenameResponse"> & { + /** + * @generated from field: liveagent.gateway.v1.ConversationSummary conversation = 1; + */ + conversation?: ConversationSummary | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryRenameResponse. + * Use `create(HistoryRenameResponseSchema)` to create a new message. + */ +export const HistoryRenameResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 70); + +/** + * Copies the conversation prefix up to and including the assistant response + * that answers the anchored user message (base_message_ref) into a brand-new + * conversation titled "新分支". + * + * @generated from message liveagent.gateway.v1.HistoryBranchRequest + */ +export type HistoryBranchRequest = Message<"liveagent.gateway.v1.HistoryBranchRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: liveagent.gateway.v1.ChatMessageRef base_message_ref = 2; + */ + baseMessageRef?: ChatMessageRef | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryBranchRequest. + * Use `create(HistoryBranchRequestSchema)` to create a new message. + */ +export const HistoryBranchRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 71); + +/** + * @generated from message liveagent.gateway.v1.HistoryBranchResponse + */ +export type HistoryBranchResponse = Message<"liveagent.gateway.v1.HistoryBranchResponse"> & { + /** + * @generated from field: liveagent.gateway.v1.ConversationSummary conversation = 1; + */ + conversation?: ConversationSummary | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryBranchResponse. + * Use `create(HistoryBranchResponseSchema)` to create a new message. + */ +export const HistoryBranchResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 72); + +/** + * @generated from message liveagent.gateway.v1.HistoryPinRequest + */ +export type HistoryPinRequest = Message<"liveagent.gateway.v1.HistoryPinRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: bool is_pinned = 2; + */ + isPinned: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryPinRequest. + * Use `create(HistoryPinRequestSchema)` to create a new message. + */ +export const HistoryPinRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 73); + +/** + * @generated from message liveagent.gateway.v1.HistoryPinResponse + */ +export type HistoryPinResponse = Message<"liveagent.gateway.v1.HistoryPinResponse"> & { + /** + * @generated from field: liveagent.gateway.v1.ConversationSummary conversation = 1; + */ + conversation?: ConversationSummary | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryPinResponse. + * Use `create(HistoryPinResponseSchema)` to create a new message. + */ +export const HistoryPinResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 74); + +/** + * @generated from message liveagent.gateway.v1.HistoryShareStatus + */ +export type HistoryShareStatus = Message<"liveagent.gateway.v1.HistoryShareStatus"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: bool enabled = 2; + */ + enabled: boolean; + + /** + * @generated from field: string token = 3; + */ + token: string; + + /** + * @generated from field: int64 created_at = 4; + */ + createdAt: bigint; + + /** + * @generated from field: int64 updated_at = 5; + */ + updatedAt: bigint; + + /** + * @generated from field: bool redact_tool_content = 6; + */ + redactToolContent: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryShareStatus. + * Use `create(HistoryShareStatusSchema)` to create a new message. + */ +export const HistoryShareStatusSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 75); + +/** + * @generated from message liveagent.gateway.v1.HistoryShareGetRequest + */ +export type HistoryShareGetRequest = Message<"liveagent.gateway.v1.HistoryShareGetRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryShareGetRequest. + * Use `create(HistoryShareGetRequestSchema)` to create a new message. + */ +export const HistoryShareGetRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 76); + +/** + * @generated from message liveagent.gateway.v1.HistoryShareGetResponse + */ +export type HistoryShareGetResponse = Message<"liveagent.gateway.v1.HistoryShareGetResponse"> & { + /** + * @generated from field: liveagent.gateway.v1.HistoryShareStatus share = 1; + */ + share?: HistoryShareStatus | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryShareGetResponse. + * Use `create(HistoryShareGetResponseSchema)` to create a new message. + */ +export const HistoryShareGetResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 77); + +/** + * @generated from message liveagent.gateway.v1.HistoryShareSetRequest + */ +export type HistoryShareSetRequest = Message<"liveagent.gateway.v1.HistoryShareSetRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: bool enabled = 2; + */ + enabled: boolean; + + /** + * @generated from field: optional bool redact_tool_content = 3; + */ + redactToolContent?: boolean | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryShareSetRequest. + * Use `create(HistoryShareSetRequestSchema)` to create a new message. + */ +export const HistoryShareSetRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 78); + +/** + * @generated from message liveagent.gateway.v1.HistoryShareSetResponse + */ +export type HistoryShareSetResponse = Message<"liveagent.gateway.v1.HistoryShareSetResponse"> & { + /** + * @generated from field: liveagent.gateway.v1.HistoryShareStatus share = 1; + */ + share?: HistoryShareStatus | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryShareSetResponse. + * Use `create(HistoryShareSetResponseSchema)` to create a new message. + */ +export const HistoryShareSetResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 79); + +/** + * @generated from message liveagent.gateway.v1.HistoryShareResolveRequest + */ +export type HistoryShareResolveRequest = Message<"liveagent.gateway.v1.HistoryShareResolveRequest"> & { + /** + * @generated from field: string token = 1; + */ + token: string; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryShareResolveRequest. + * Use `create(HistoryShareResolveRequestSchema)` to create a new message. + */ +export const HistoryShareResolveRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 80); + +/** + * @generated from message liveagent.gateway.v1.HistoryShareResolveResponse + */ +export type HistoryShareResolveResponse = Message<"liveagent.gateway.v1.HistoryShareResolveResponse"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string messages_json = 2; + */ + messagesJson: string; + + /** + * @generated from field: int32 total_message_count = 3; + */ + totalMessageCount: number; + + /** + * @generated from field: liveagent.gateway.v1.ConversationSummary conversation = 4; + */ + conversation?: ConversationSummary | undefined; + + /** + * @generated from field: bool redact_tool_content = 5; + */ + redactToolContent: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryShareResolveResponse. + * Use `create(HistoryShareResolveResponseSchema)` to create a new message. + */ +export const HistoryShareResolveResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 81); + +/** + * @generated from message liveagent.gateway.v1.HistoryWorkdirsRequest + */ +export type HistoryWorkdirsRequest = Message<"liveagent.gateway.v1.HistoryWorkdirsRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryWorkdirsRequest. + * Use `create(HistoryWorkdirsRequestSchema)` to create a new message. + */ +export const HistoryWorkdirsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 82); + +/** + * @generated from message liveagent.gateway.v1.HistoryWorkdirSummary + */ +export type HistoryWorkdirSummary = Message<"liveagent.gateway.v1.HistoryWorkdirSummary"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: int32 conversation_count = 2; + */ + conversationCount: number; + + /** + * @generated from field: int64 updated_at = 3; + */ + updatedAt: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryWorkdirSummary. + * Use `create(HistoryWorkdirSummarySchema)` to create a new message. + */ +export const HistoryWorkdirSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 83); + +/** + * @generated from message liveagent.gateway.v1.HistoryWorkdirsResponse + */ +export type HistoryWorkdirsResponse = Message<"liveagent.gateway.v1.HistoryWorkdirsResponse"> & { + /** + * @generated from field: repeated liveagent.gateway.v1.HistoryWorkdirSummary workdirs = 1; + */ + workdirs: HistoryWorkdirSummary[]; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryWorkdirsResponse. + * Use `create(HistoryWorkdirsResponseSchema)` to create a new message. + */ +export const HistoryWorkdirsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 84); + +/** + * @generated from message liveagent.gateway.v1.HistoryDeleteRequest + */ +export type HistoryDeleteRequest = Message<"liveagent.gateway.v1.HistoryDeleteRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryDeleteRequest. + * Use `create(HistoryDeleteRequestSchema)` to create a new message. + */ +export const HistoryDeleteRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 85); + +/** + * @generated from message liveagent.gateway.v1.HistoryDeleteResponse + */ +export type HistoryDeleteResponse = Message<"liveagent.gateway.v1.HistoryDeleteResponse"> & { +}; + +/** + * Describes the message liveagent.gateway.v1.HistoryDeleteResponse. + * Use `create(HistoryDeleteResponseSchema)` to create a new message. + */ +export const HistoryDeleteResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 86); + +/** + * @generated from message liveagent.gateway.v1.HistorySyncEvent + */ +export type HistorySyncEvent = Message<"liveagent.gateway.v1.HistorySyncEvent"> & { + /** + * @generated from field: string kind = 1; + */ + kind: string; + + /** + * @generated from field: liveagent.gateway.v1.ConversationSummary conversation = 2; + */ + conversation?: ConversationSummary | undefined; + + /** + * @generated from field: string conversation_id = 3; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v1.HistorySyncEvent. + * Use `create(HistorySyncEventSchema)` to create a new message. + */ +export const HistorySyncEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 87); + +/** + * @generated from message liveagent.gateway.v1.ProviderListRequest + */ +export type ProviderListRequest = Message<"liveagent.gateway.v1.ProviderListRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v1.ProviderListRequest. + * Use `create(ProviderListRequestSchema)` to create a new message. + */ +export const ProviderListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 88); + +/** + * @generated from message liveagent.gateway.v1.ProviderListResponse + */ +export type ProviderListResponse = Message<"liveagent.gateway.v1.ProviderListResponse"> & { + /** + * @generated from field: string providers_json = 1; + */ + providersJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.ProviderListResponse. + * Use `create(ProviderListResponseSchema)` to create a new message. + */ +export const ProviderListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 89); + +/** + * @generated from message liveagent.gateway.v1.SettingsGetRequest + */ +export type SettingsGetRequest = Message<"liveagent.gateway.v1.SettingsGetRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v1.SettingsGetRequest. + * Use `create(SettingsGetRequestSchema)` to create a new message. + */ +export const SettingsGetRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 90); + +/** + * @generated from message liveagent.gateway.v1.SettingsGetResponse + */ +export type SettingsGetResponse = Message<"liveagent.gateway.v1.SettingsGetResponse"> & { + /** + * @generated from field: string settings_json = 1; + */ + settingsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.SettingsGetResponse. + * Use `create(SettingsGetResponseSchema)` to create a new message. + */ +export const SettingsGetResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 91); + +/** + * @generated from message liveagent.gateway.v1.SettingsUpdateRequest + */ +export type SettingsUpdateRequest = Message<"liveagent.gateway.v1.SettingsUpdateRequest"> & { + /** + * @generated from field: string settings_json = 1; + */ + settingsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.SettingsUpdateRequest. + * Use `create(SettingsUpdateRequestSchema)` to create a new message. + */ +export const SettingsUpdateRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 92); + +/** + * @generated from message liveagent.gateway.v1.SettingsUpdateResponse + */ +export type SettingsUpdateResponse = Message<"liveagent.gateway.v1.SettingsUpdateResponse"> & { + /** + * @generated from field: bool accepted = 1; + */ + accepted: boolean; + + /** + * @generated from field: string message = 2; + */ + message: string; +}; + +/** + * Describes the message liveagent.gateway.v1.SettingsUpdateResponse. + * Use `create(SettingsUpdateResponseSchema)` to create a new message. + */ +export const SettingsUpdateResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 93); + +/** + * @generated from message liveagent.gateway.v1.SettingsResetSshKnownHostRequest + */ +export type SettingsResetSshKnownHostRequest = Message<"liveagent.gateway.v1.SettingsResetSshKnownHostRequest"> & { + /** + * @generated from field: string host = 1; + */ + host: string; + + /** + * @generated from field: uint32 port = 2; + */ + port: number; +}; + +/** + * Describes the message liveagent.gateway.v1.SettingsResetSshKnownHostRequest. + * Use `create(SettingsResetSshKnownHostRequestSchema)` to create a new message. + */ +export const SettingsResetSshKnownHostRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 94); + +/** + * @generated from message liveagent.gateway.v1.SettingsResetSshKnownHostResponse + */ +export type SettingsResetSshKnownHostResponse = Message<"liveagent.gateway.v1.SettingsResetSshKnownHostResponse"> & { + /** + * @generated from field: uint32 deleted = 1; + */ + deleted: number; +}; + +/** + * Describes the message liveagent.gateway.v1.SettingsResetSshKnownHostResponse. + * Use `create(SettingsResetSshKnownHostResponseSchema)` to create a new message. + */ +export const SettingsResetSshKnownHostResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 95); + +/** + * @generated from message liveagent.gateway.v1.SettingsSyncEvent + */ +export type SettingsSyncEvent = Message<"liveagent.gateway.v1.SettingsSyncEvent"> & { + /** + * @generated from field: string settings_json = 1; + */ + settingsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.SettingsSyncEvent. + * Use `create(SettingsSyncEventSchema)` to create a new message. + */ +export const SettingsSyncEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 96); + +/** + * @generated from message liveagent.gateway.v1.SkillFilesListRequest + */ +export type SkillFilesListRequest = Message<"liveagent.gateway.v1.SkillFilesListRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v1.SkillFilesListRequest. + * Use `create(SkillFilesListRequestSchema)` to create a new message. + */ +export const SkillFilesListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 97); + +/** + * @generated from message liveagent.gateway.v1.SkillFilesListResponse + */ +export type SkillFilesListResponse = Message<"liveagent.gateway.v1.SkillFilesListResponse"> & { + /** + * @generated from field: string root_dir = 1; + */ + rootDir: string; + + /** + * @generated from field: repeated string paths = 2; + */ + paths: string[]; + + /** + * @generated from field: bool truncated = 3; + */ + truncated: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.SkillFilesListResponse. + * Use `create(SkillFilesListResponseSchema)` to create a new message. + */ +export const SkillFilesListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 98); + +/** + * @generated from message liveagent.gateway.v1.SkillMetadataReadRequest + */ +export type SkillMetadataReadRequest = Message<"liveagent.gateway.v1.SkillMetadataReadRequest"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v1.SkillMetadataReadRequest. + * Use `create(SkillMetadataReadRequestSchema)` to create a new message. + */ +export const SkillMetadataReadRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 99); + +/** + * @generated from message liveagent.gateway.v1.SkillMetadataReadResponse + */ +export type SkillMetadataReadResponse = Message<"liveagent.gateway.v1.SkillMetadataReadResponse"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: string description = 2; + */ + description: string; +}; + +/** + * Describes the message liveagent.gateway.v1.SkillMetadataReadResponse. + * Use `create(SkillMetadataReadResponseSchema)` to create a new message. + */ +export const SkillMetadataReadResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 100); + +/** + * @generated from message liveagent.gateway.v1.SkillTextReadRequest + */ +export type SkillTextReadRequest = Message<"liveagent.gateway.v1.SkillTextReadRequest"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: uint32 offset = 2; + */ + offset: number; + + /** + * @generated from field: uint32 length = 3; + */ + length: number; +}; + +/** + * Describes the message liveagent.gateway.v1.SkillTextReadRequest. + * Use `create(SkillTextReadRequestSchema)` to create a new message. + */ +export const SkillTextReadRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 101); + +/** + * @generated from message liveagent.gateway.v1.SkillTextReadResponse + */ +export type SkillTextReadResponse = Message<"liveagent.gateway.v1.SkillTextReadResponse"> & { + /** + * @generated from field: string content = 1; + */ + content: string; + + /** + * @generated from field: bool truncated = 2; + */ + truncated: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.SkillTextReadResponse. + * Use `create(SkillTextReadResponseSchema)` to create a new message. + */ +export const SkillTextReadResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 102); + +/** + * @generated from message liveagent.gateway.v1.SkillManageRequest + */ +export type SkillManageRequest = Message<"liveagent.gateway.v1.SkillManageRequest"> & { + /** + * @generated from field: string payload_json = 1; + */ + payloadJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.SkillManageRequest. + * Use `create(SkillManageRequestSchema)` to create a new message. + */ +export const SkillManageRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 103); + +/** + * @generated from message liveagent.gateway.v1.SkillManageResponse + */ +export type SkillManageResponse = Message<"liveagent.gateway.v1.SkillManageResponse"> & { + /** + * @generated from field: string result_json = 1; + */ + resultJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.SkillManageResponse. + * Use `create(SkillManageResponseSchema)` to create a new message. + */ +export const SkillManageResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 104); + +/** + * @generated from message liveagent.gateway.v1.FileMentionListRequest + */ +export type FileMentionListRequest = Message<"liveagent.gateway.v1.FileMentionListRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: uint32 max_results = 2; + */ + maxResults: number; + + /** + * @generated from field: string query = 3; + */ + query: string; + + /** + * @generated from field: optional bool show_hidden = 4; + */ + showHidden?: boolean | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.FileMentionListRequest. + * Use `create(FileMentionListRequestSchema)` to create a new message. + */ +export const FileMentionListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 105); + +/** + * @generated from message liveagent.gateway.v1.FileMentionEntry + */ +export type FileMentionEntry = Message<"liveagent.gateway.v1.FileMentionEntry"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; + + /** + * @generated from field: bool hidden = 3; + */ + hidden: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.FileMentionEntry. + * Use `create(FileMentionEntrySchema)` to create a new message. + */ +export const FileMentionEntrySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 106); + +/** + * @generated from message liveagent.gateway.v1.FileMentionListResponse + */ +export type FileMentionListResponse = Message<"liveagent.gateway.v1.FileMentionListResponse"> & { + /** + * @generated from field: repeated liveagent.gateway.v1.FileMentionEntry entries = 1; + */ + entries: FileMentionEntry[]; + + /** + * @generated from field: bool truncated = 2; + */ + truncated: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.FileMentionListResponse. + * Use `create(FileMentionListResponseSchema)` to create a new message. + */ +export const FileMentionListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 107); + +/** + * @generated from message liveagent.gateway.v1.FsRoot + */ +export type FsRoot = Message<"liveagent.gateway.v1.FsRoot"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: string kind = 3; + */ + kind: string; + + /** + * @generated from field: string label = 4; + */ + label: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsRoot. + * Use `create(FsRootSchema)` to create a new message. + */ +export const FsRootSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 108); + +/** + * @generated from message liveagent.gateway.v1.FsRootsRequest + */ +export type FsRootsRequest = Message<"liveagent.gateway.v1.FsRootsRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v1.FsRootsRequest. + * Use `create(FsRootsRequestSchema)` to create a new message. + */ +export const FsRootsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 109); + +/** + * @generated from message liveagent.gateway.v1.FsRootsResponse + */ +export type FsRootsResponse = Message<"liveagent.gateway.v1.FsRootsResponse"> & { + /** + * @generated from field: repeated liveagent.gateway.v1.FsRoot roots = 1; + */ + roots: FsRoot[]; +}; + +/** + * Describes the message liveagent.gateway.v1.FsRootsResponse. + * Use `create(FsRootsResponseSchema)` to create a new message. + */ +export const FsRootsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 110); + +/** + * @generated from message liveagent.gateway.v1.FsListDirsRequest + */ +export type FsListDirsRequest = Message<"liveagent.gateway.v1.FsListDirsRequest"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: uint32 max_results = 2; + */ + maxResults: number; +}; + +/** + * Describes the message liveagent.gateway.v1.FsListDirsRequest. + * Use `create(FsListDirsRequestSchema)` to create a new message. + */ +export const FsListDirsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 111); + +/** + * @generated from message liveagent.gateway.v1.FsDirEntry + */ +export type FsDirEntry = Message<"liveagent.gateway.v1.FsDirEntry"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsDirEntry. + * Use `create(FsDirEntrySchema)` to create a new message. + */ +export const FsDirEntrySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 112); + +/** + * @generated from message liveagent.gateway.v1.FsListDirsResponse + */ +export type FsListDirsResponse = Message<"liveagent.gateway.v1.FsListDirsResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: repeated liveagent.gateway.v1.FsDirEntry entries = 2; + */ + entries: FsDirEntry[]; + + /** + * @generated from field: bool truncated = 3; + */ + truncated: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.FsListDirsResponse. + * Use `create(FsListDirsResponseSchema)` to create a new message. + */ +export const FsListDirsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 113); + +/** + * @generated from message liveagent.gateway.v1.FsCreateProjectFolderRequest + */ +export type FsCreateProjectFolderRequest = Message<"liveagent.gateway.v1.FsCreateProjectFolderRequest"> & { + /** + * @generated from field: string parent = 1; + */ + parent: string; + + /** + * @generated from field: string name = 2; + */ + name: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsCreateProjectFolderRequest. + * Use `create(FsCreateProjectFolderRequestSchema)` to create a new message. + */ +export const FsCreateProjectFolderRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 114); + +/** + * @generated from message liveagent.gateway.v1.FsCreateProjectFolderResponse + */ +export type FsCreateProjectFolderResponse = Message<"liveagent.gateway.v1.FsCreateProjectFolderResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsCreateProjectFolderResponse. + * Use `create(FsCreateProjectFolderResponseSchema)` to create a new message. + */ +export const FsCreateProjectFolderResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 115); + +/** + * @generated from message liveagent.gateway.v1.FsListRequest + */ +export type FsListRequest = Message<"liveagent.gateway.v1.FsListRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: uint32 depth = 3; + */ + depth: number; + + /** + * @generated from field: uint32 offset = 4; + */ + offset: number; + + /** + * @generated from field: uint32 max_results = 5; + */ + maxResults: number; + + /** + * @generated from field: optional bool show_hidden = 6; + */ + showHidden?: boolean | undefined; +}; + +/** + * Describes the message liveagent.gateway.v1.FsListRequest. + * Use `create(FsListRequestSchema)` to create a new message. + */ +export const FsListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 116); + +/** + * @generated from message liveagent.gateway.v1.FsListEntry + */ +export type FsListEntry = Message<"liveagent.gateway.v1.FsListEntry"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; + + /** + * @generated from field: bool hidden = 3; + */ + hidden: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.FsListEntry. + * Use `create(FsListEntrySchema)` to create a new message. + */ +export const FsListEntrySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 117); + +/** + * @generated from message liveagent.gateway.v1.FsListResponse + */ +export type FsListResponse = Message<"liveagent.gateway.v1.FsListResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: bool has_path = 2; + */ + hasPath: boolean; + + /** + * @generated from field: uint32 depth = 3; + */ + depth: number; + + /** + * @generated from field: uint32 offset = 4; + */ + offset: number; + + /** + * @generated from field: uint32 max_results = 5; + */ + maxResults: number; + + /** + * @generated from field: uint32 total = 6; + */ + total: number; + + /** + * @generated from field: bool has_more = 7; + */ + hasMore: boolean; + + /** + * @generated from field: repeated liveagent.gateway.v1.FsListEntry entries = 8; + */ + entries: FsListEntry[]; +}; + +/** + * Describes the message liveagent.gateway.v1.FsListResponse. + * Use `create(FsListResponseSchema)` to create a new message. + */ +export const FsListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 118); + +/** + * @generated from message liveagent.gateway.v1.FsReadEditableTextRequest + */ +export type FsReadEditableTextRequest = Message<"liveagent.gateway.v1.FsReadEditableTextRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsReadEditableTextRequest. + * Use `create(FsReadEditableTextRequestSchema)` to create a new message. + */ +export const FsReadEditableTextRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 119); + +/** + * @generated from message liveagent.gateway.v1.FsReadEditableTextResponse + */ +export type FsReadEditableTextResponse = Message<"liveagent.gateway.v1.FsReadEditableTextResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string content = 2; + */ + content: string; + + /** + * @generated from field: uint64 mtime_ms = 3; + */ + mtimeMs: bigint; + + /** + * @generated from field: string content_hash = 4; + */ + contentHash: string; + + /** + * @generated from field: uint64 size_bytes = 5; + */ + sizeBytes: bigint; + + /** + * @generated from field: uint64 total_lines = 6; + */ + totalLines: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.FsReadEditableTextResponse. + * Use `create(FsReadEditableTextResponseSchema)` to create a new message. + */ +export const FsReadEditableTextResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 120); + +/** + * @generated from message liveagent.gateway.v1.FsReadWorkspaceImageRequest + */ +export type FsReadWorkspaceImageRequest = Message<"liveagent.gateway.v1.FsReadWorkspaceImageRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsReadWorkspaceImageRequest. + * Use `create(FsReadWorkspaceImageRequestSchema)` to create a new message. + */ +export const FsReadWorkspaceImageRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 121); + +/** + * @generated from message liveagent.gateway.v1.FsReadWorkspaceImageResponse + */ +export type FsReadWorkspaceImageResponse = Message<"liveagent.gateway.v1.FsReadWorkspaceImageResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string mime_type = 2; + */ + mimeType: string; + + /** + * @generated from field: string data = 3; + */ + data: string; + + /** + * @generated from field: uint64 size_bytes = 4; + */ + sizeBytes: bigint; + + /** + * @generated from field: uint64 mtime_ms = 5; + */ + mtimeMs: bigint; + + /** + * @generated from field: string content_hash = 6; + */ + contentHash: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsReadWorkspaceImageResponse. + * Use `create(FsReadWorkspaceImageResponseSchema)` to create a new message. + */ +export const FsReadWorkspaceImageResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 122); + +/** + * @generated from message liveagent.gateway.v1.FsWriteTextRequest + */ +export type FsWriteTextRequest = Message<"liveagent.gateway.v1.FsWriteTextRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: string content = 3; + */ + content: string; + + /** + * @generated from field: string mode = 4; + */ + mode: string; + + /** + * @generated from field: uint64 expected_mtime_ms = 5; + */ + expectedMtimeMs: bigint; + + /** + * @generated from field: string expected_content_hash = 6; + */ + expectedContentHash: string; + + /** + * @generated from field: bool has_expected_mtime_ms = 7; + */ + hasExpectedMtimeMs: boolean; + + /** + * @generated from field: bool has_expected_content_hash = 8; + */ + hasExpectedContentHash: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.FsWriteTextRequest. + * Use `create(FsWriteTextRequestSchema)` to create a new message. + */ +export const FsWriteTextRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 123); + +/** + * @generated from message liveagent.gateway.v1.FsWriteTextResponse + */ +export type FsWriteTextResponse = Message<"liveagent.gateway.v1.FsWriteTextResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string mode = 2; + */ + mode: string; + + /** + * @generated from field: bool existed_before = 3; + */ + existedBefore: boolean; + + /** + * @generated from field: uint64 bytes_written = 4; + */ + bytesWritten: bigint; + + /** + * @generated from field: uint64 mtime_ms = 5; + */ + mtimeMs: bigint; + + /** + * @generated from field: string content_hash = 6; + */ + contentHash: string; + + /** + * @generated from field: uint64 total_lines = 7; + */ + totalLines: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.FsWriteTextResponse. + * Use `create(FsWriteTextResponseSchema)` to create a new message. + */ +export const FsWriteTextResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 124); + +/** + * @generated from message liveagent.gateway.v1.FsCreateDirRequest + */ +export type FsCreateDirRequest = Message<"liveagent.gateway.v1.FsCreateDirRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsCreateDirRequest. + * Use `create(FsCreateDirRequestSchema)` to create a new message. + */ +export const FsCreateDirRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 125); + +/** + * @generated from message liveagent.gateway.v1.FsCreateDirResponse + */ +export type FsCreateDirResponse = Message<"liveagent.gateway.v1.FsCreateDirResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsCreateDirResponse. + * Use `create(FsCreateDirResponseSchema)` to create a new message. + */ +export const FsCreateDirResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 126); + +/** + * @generated from message liveagent.gateway.v1.FsRenameRequest + */ +export type FsRenameRequest = Message<"liveagent.gateway.v1.FsRenameRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string from_path = 2; + */ + fromPath: string; + + /** + * @generated from field: string to_path = 3; + */ + toPath: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsRenameRequest. + * Use `create(FsRenameRequestSchema)` to create a new message. + */ +export const FsRenameRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 127); + +/** + * @generated from message liveagent.gateway.v1.FsRenameResponse + */ +export type FsRenameResponse = Message<"liveagent.gateway.v1.FsRenameResponse"> & { + /** + * @generated from field: string from_path = 1; + */ + fromPath: string; + + /** + * @generated from field: string path = 2; + */ + path: string; + + /** + * @generated from field: string kind = 3; + */ + kind: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsRenameResponse. + * Use `create(FsRenameResponseSchema)` to create a new message. + */ +export const FsRenameResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 128); + +/** + * @generated from message liveagent.gateway.v1.FsDeleteRequest + */ +export type FsDeleteRequest = Message<"liveagent.gateway.v1.FsDeleteRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; + + /** + * @generated from field: string path = 2; + */ + path: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsDeleteRequest. + * Use `create(FsDeleteRequestSchema)` to create a new message. + */ +export const FsDeleteRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 129); + +/** + * @generated from message liveagent.gateway.v1.FsDeleteResponse + */ +export type FsDeleteResponse = Message<"liveagent.gateway.v1.FsDeleteResponse"> & { + /** + * @generated from field: string path = 1; + */ + path: string; + + /** + * @generated from field: string kind = 2; + */ + kind: string; +}; + +/** + * Describes the message liveagent.gateway.v1.FsDeleteResponse. + * Use `create(FsDeleteResponseSchema)` to create a new message. + */ +export const FsDeleteResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 130); + +/** + * @generated from message liveagent.gateway.v1.PingRequest + */ +export type PingRequest = Message<"liveagent.gateway.v1.PingRequest"> & { + /** + * @generated from field: int64 timestamp = 1; + */ + timestamp: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.PingRequest. + * Use `create(PingRequestSchema)` to create a new message. + */ +export const PingRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 131); + +/** + * @generated from message liveagent.gateway.v1.PongResponse + */ +export type PongResponse = Message<"liveagent.gateway.v1.PongResponse"> & { + /** + * @generated from field: int64 timestamp = 1; + */ + timestamp: bigint; +}; + +/** + * Describes the message liveagent.gateway.v1.PongResponse. + * Use `create(PongResponseSchema)` to create a new message. + */ +export const PongResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 132); + +/** + * @generated from message liveagent.gateway.v1.ErrorResponse + */ +export type ErrorResponse = Message<"liveagent.gateway.v1.ErrorResponse"> & { + /** + * @generated from field: int32 code = 1; + */ + code: number; + + /** + * @generated from field: string message = 2; + */ + message: string; +}; + +/** + * Describes the message liveagent.gateway.v1.ErrorResponse. + * Use `create(ErrorResponseSchema)` to create a new message. + */ +export const ErrorResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 133); + +/** + * @generated from message liveagent.gateway.v1.ProviderModelsRequest + */ +export type ProviderModelsRequest = Message<"liveagent.gateway.v1.ProviderModelsRequest"> & { + /** + * @generated from field: string provider_type = 1; + */ + providerType: string; + + /** + * @generated from field: string base_url = 2; + */ + baseUrl: string; + + /** + * @generated from field: string api_key = 3; + */ + apiKey: string; + + /** + * @generated from field: bool use_system_proxy = 4; + */ + useSystemProxy: boolean; +}; + +/** + * Describes the message liveagent.gateway.v1.ProviderModelsRequest. + * Use `create(ProviderModelsRequestSchema)` to create a new message. + */ +export const ProviderModelsRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 134); + +/** + * @generated from message liveagent.gateway.v1.ProviderModelsResponse + */ +export type ProviderModelsResponse = Message<"liveagent.gateway.v1.ProviderModelsResponse"> & { + /** + * @generated from field: string models_json = 1; + */ + modelsJson: string; +}; + +/** + * Describes the message liveagent.gateway.v1.ProviderModelsResponse. + * Use `create(ProviderModelsResponseSchema)` to create a new message. + */ +export const ProviderModelsResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v1_gateway, 135); + +/** + * @generated from enum liveagent.gateway.v1.TunnelFrameKind + */ +export enum TunnelFrameKind { + /** + * @generated from enum value: TUNNEL_FRAME_KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_REQUEST_START = 1; + */ + HTTP_REQUEST_START = 1, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY = 2; + */ + HTTP_REQUEST_BODY = 2, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_REQUEST_END = 3; + */ + HTTP_REQUEST_END = 3, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_RESPONSE_START = 4; + */ + HTTP_RESPONSE_START = 4, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY = 5; + */ + HTTP_RESPONSE_BODY = 5, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_HTTP_RESPONSE_END = 6; + */ + HTTP_RESPONSE_END = 6, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_WS_DIAL = 7; + */ + WS_DIAL = 7, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_WS_DIAL_OK = 8; + */ + WS_DIAL_OK = 8, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_WS_DIAL_ERROR = 9; + */ + WS_DIAL_ERROR = 9, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_WS_FRAME = 10; + */ + WS_FRAME = 10, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_WS_CLOSE = 11; + */ + WS_CLOSE = 11, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_ERROR = 12; + */ + ERROR = 12, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_CANCEL = 13; + */ + CANCEL = 13, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_PING = 14; + */ + PING = 14, + + /** + * @generated from enum value: TUNNEL_FRAME_KIND_PONG = 15; + */ + PONG = 15, +} + +/** + * Describes the enum liveagent.gateway.v1.TunnelFrameKind. + */ +export const TunnelFrameKindSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_proto_v1_gateway, 0); + +/** + * @generated from enum liveagent.gateway.v1.TunnelWsMessageType + */ +export enum TunnelWsMessageType { + /** + * @generated from enum value: TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: TUNNEL_WS_MESSAGE_TYPE_TEXT = 1; + */ + TEXT = 1, + + /** + * @generated from enum value: TUNNEL_WS_MESSAGE_TYPE_BINARY = 2; + */ + BINARY = 2, +} + +/** + * Describes the enum liveagent.gateway.v1.TunnelWsMessageType. + */ +export const TunnelWsMessageTypeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_proto_v1_gateway, 1); + +/** + * AgentGateway 是 v1 的桌面端 gRPC 服务。已弃用:v2 起由 /ws/v2/agent 与 /ws/v2/terminal + * (见 proto/v2/gateway_ws.proto)承担,仅为旧版桌面客户端保留,流量归零后删除; + * 本文件的消息是 v2 的业务载荷,全部保留、永不弃用。 + * + * @generated from service liveagent.gateway.v1.AgentGateway + * @deprecated + */ +export const AgentGateway: GenService<{ + /** + * @generated from rpc liveagent.gateway.v1.AgentGateway.AgentConnect + */ + agentConnect: { + methodKind: "bidi_streaming"; + input: typeof AgentEnvelopeSchema; + output: typeof GatewayEnvelopeSchema; + }, + /** + * @generated from rpc liveagent.gateway.v1.AgentGateway.AgentTerminalConnect + */ + agentTerminalConnect: { + methodKind: "bidi_streaming"; + input: typeof TerminalStreamFrameSchema; + output: typeof TerminalStreamFrameSchema; + }, + /** + * @generated from rpc liveagent.gateway.v1.AgentGateway.Authenticate + */ + authenticate: { + methodKind: "unary"; + input: typeof AuthRequestSchema; + output: typeof AuthResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_proto_v1_gateway, 0); + diff --git a/crates/agent-gateway/web/src/lib/proto/gen/proto/v2/gateway_ws_pb.ts b/crates/agent-gateway/web/src/lib/proto/gen/proto/v2/gateway_ws_pb.ts new file mode 100644 index 000000000..524f788aa --- /dev/null +++ b/crates/agent-gateway/web/src/lib/proto/gen/proto/v2/gateway_ws_pb.ts @@ -0,0 +1,1195 @@ +// v2 统一线协议(WebSocket+Protobuf):/ws/v2(浏览器)、/ws/v2/agent(桌面端)、 +// /ws/v2/terminal(终端数据面)三链路的帧壳。一条 WS 二进制消息承载一条帧,文本帧即协议错误; +// 首帧必须为 hello,鉴权失败以 close code 4401 关闭。业务载荷复用 v1 消息(三端唯一事实源, +// 永不弃用、编号只增不改),本文件仅新增帧壳与未曾 proto 化的网关本地载荷。 + +// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated from file proto/v2/gateway_ws.proto (package liveagent.gateway.v2, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { AgentEnvelope, ChatCommandRequest, ChatQueueEvent, ErrorResponse, GatewayEnvelope, HistorySyncEvent, ManagedProcessSnapshot, SettingsSyncEvent, SftpEvent, TerminalEvent, TerminalStreamFrame, TunnelStateSnapshot, WorkspaceActivityEvent } from "../v1/gateway_pb"; +import { file_proto_v1_gateway } from "../v1/gateway_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file proto/v2/gateway_ws.proto. + */ +export const file_proto_v2_gateway_ws: GenFile = /*@__PURE__*/ + fileDesc("Chlwcm90by92Mi9nYXRld2F5X3dzLnByb3RvEhRsaXZlYWdlbnQuZ2F0ZXdheS52MiK8AQoLQ2xpZW50SGVsbG8SGAoQcHJvdG9jb2xfdmVyc2lvbhgBIAEoDRIuCgRyb2xlGAIgASgOMiAubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2xpZW50Um9sZRINCgV0b2tlbhgDIAEoCRIQCghhZ2VudF9pZBgEIAEoCRIVCg1hZ2VudF92ZXJzaW9uGAUgASgJEhMKC2NsaWVudF9uYW1lGAYgASgJEhYKDmNsaWVudF92ZXJzaW9uGAcgASgJIpABCgtTZXJ2ZXJIZWxsbxIKCgJvaxgBIAEoCBIPCgdtZXNzYWdlGAIgASgJEhIKCnNlc3Npb25faWQYAyABKAkSEwoLc2VydmVyX3RpbWUYBCABKAMSIAoYaGVhcnRiZWF0X3BlcmlvZF9zZWNvbmRzGAUgASgNEhkKEW1heF9tZXNzYWdlX2J5dGVzGAYgASgEIh4KCVBpbmdGcmFtZRIRCgl0aW1lc3RhbXAYASABKAMiHgoJUG9uZ0ZyYW1lEhEKCXRpbWVzdGFtcBgBIAEoAyIXCglBY2tSZXN1bHQSCgoCb2sYASABKAgikgYKDldlYkNsaWVudEZyYW1lEhIKCnJlcXVlc3RfaWQYASABKAkSMgoFaGVsbG8YAiABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DbGllbnRIZWxsb0gAEj4KDWFnZW50X3JlcXVlc3QYAyABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52MS5HYXRld2F5RW52ZWxvcGVIABI8CgpzdGF0dXNfZ2V0GAQgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuU3RhdHVzR2V0UmVxdWVzdEgAEkAKDGNoYXRfY29tbWFuZBgFIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRDb21tYW5kUmVxdWVzdEgAEkAKDGNoYXRfcHJlcGFyZRgGIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRQcmVwYXJlUmVxdWVzdEgAEkQKDmNoYXRfc3Vic2NyaWJlGAcgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFN1YnNjcmliZVJlcXVlc3RIABJIChBjaGF0X3Vuc3Vic2NyaWJlGAggASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFVuc3Vic2NyaWJlUmVxdWVzdEgAEkYKD2NoYXRfYWN0aXZpdGllcxgJIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRBY3Rpdml0aWVzUmVxdWVzdEgAEk4KE3dvcmtzcGFjZV9zdWJzY3JpYmUYCiABKAsyLy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Xb3Jrc3BhY2VTdWJzY3JpYmVSZXF1ZXN0SAASUgoVd29ya3NwYWNlX3Vuc3Vic2NyaWJlGAsgASgLMjEubGl2ZWFnZW50LmdhdGV3YXkudjIuV29ya3NwYWNlVW5zdWJzY3JpYmVSZXF1ZXN0SAASLwoEcG9uZxgMIAEoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYyLlBvbmdGcmFtZUgAQgkKB3BheWxvYWQisAsKDldlYlNlcnZlckZyYW1lEhIKCnJlcXVlc3RfaWQYASABKAkSMgoFaGVsbG8YAiABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXJ2ZXJIZWxsb0gAEj0KDmFnZW50X3Jlc3BvbnNlGAMgASgLMiMubGl2ZWFnZW50LmdhdGV3YXkudjEuQWdlbnRFbnZlbG9wZUgAEjoKC2xvY2FsX2Vycm9yGAQgASgLMiMubGl2ZWFnZW50LmdhdGV3YXkudjEuRXJyb3JSZXNwb25zZUgAEi8KBHBpbmcYBSABKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5QaW5nRnJhbWVIABIzCgZzdGF0dXMYBiABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TdGF0dXNFdmVudEgAEkQKD2NoYXRfc3Vic2NyaWJlZBgHIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRTdWJzY3JpYmVSZXN1bHRIABJCCg1jaGF0X2FjY2VwdGVkGAggASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdENvbW1hbmRBY2NlcHRlZEgAEkUKD2NoYXRfYWN0aXZpdGllcxgJIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRBY3Rpdml0aWVzUmVzdWx0SAASOwoKY2hhdF9ldmVudBgKIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRTdHJlYW1FdmVudEgAEkYKE2NoYXRfY29tbWFuZF91cGRhdGUYCyABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0Q29tbWFuZFVwZGF0ZUgAEk4KF2NoYXRfc3Vic2NyaXB0aW9uX3Jlc2V0GAwgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFN1YnNjcmlwdGlvblJlc2V0SAASQAoNY2hhdF9hY3Rpdml0eRgNIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRBY3Rpdml0eUV2ZW50SAASLgoDYWNrGA4gASgLMh8ubGl2ZWFnZW50LmdhdGV3YXkudjIuQWNrUmVzdWx0SAASQAoOY2hhdF9jYW5jZWxsZWQYDyABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0Q2FuY2VsUmVzdWx0SAASPwoNaGlzdG9yeV9ldmVudBgUIAEoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlTeW5jRXZlbnRIABJBCg5zZXR0aW5nc19ldmVudBgVIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzU3luY0V2ZW50SAASPQoOdGVybWluYWxfZXZlbnQYFiABKAsyIy5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbEV2ZW50SAASNQoKc2Z0cF9ldmVudBgXIAEoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNmdHBFdmVudEgAEkAKEGNoYXRfcXVldWVfZXZlbnQYGCABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0UXVldWVFdmVudEgAEkEKDHR1bm5lbF9zdGF0ZRgZIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbFN0YXRlU25hcHNob3RIABJFCg1wcm9jZXNzX3N0YXRlGBogASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuTWFuYWdlZFByb2Nlc3NTbmFwc2hvdEgAEkoKEndvcmtzcGFjZV9hY3Rpdml0eRgbIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLldvcmtzcGFjZUFjdGl2aXR5RXZlbnRIAEIJCgdwYXlsb2FkIooBChBBZ2VudENsaWVudEZyYW1lEjIKBWhlbGxvGAEgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2xpZW50SGVsbG9IABI3CghlbnZlbG9wZRgCIAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYxLkFnZW50RW52ZWxvcGVIAEIJCgdwYXlsb2FkIowBChBBZ2VudFNlcnZlckZyYW1lEjIKBWhlbGxvGAEgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuU2VydmVySGVsbG9IABI5CghlbnZlbG9wZRgCIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLkdhdGV3YXlFbnZlbG9wZUgAQgkKB3BheWxvYWQikAEKE1Rlcm1pbmFsQ2xpZW50RnJhbWUSMgoFaGVsbG8YASABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DbGllbnRIZWxsb0gAEjoKBWZyYW1lGAIgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTdHJlYW1GcmFtZUgAQgkKB3BheWxvYWQikAEKE1Rlcm1pbmFsU2VydmVyRnJhbWUSMgoFaGVsbG8YASABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXJ2ZXJIZWxsb0gAEjoKBWZyYW1lGAIgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTdHJlYW1GcmFtZUgAQgkKB3BheWxvYWQiEgoQU3RhdHVzR2V0UmVxdWVzdCLJAgoLU3RhdHVzRXZlbnQSDgoGb25saW5lGAEgASgIEhMKC2FnZW50X3JlYWR5GAIgASgIEhoKEmNoYXRfcnVudGltZV9yZWFkeRgDIAEoCBIQCghhZ2VudF9pZBgEIAEoCRIVCg1hZ2VudF92ZXJzaW9uGAUgASgJEhIKCnNlc3Npb25faWQYBiABKAkSFwoPY29ubmVjdGVkX3NpbmNlGAcgASgDEhYKDmxhc3RfaGVhcnRiZWF0GAggASgDEhUKDXJ1bnRpbWVfc3RhdGUYCSABKAkSHgoWcnVudGltZV9sYXN0X2hlYXJ0YmVhdBgKIAEoAxIZChFydW50aW1lX3dvcmtlcl9pZBgLIAEoCRIXCg9ydW50aW1lX3Zpc2libGUYDCABKAgSIAoYcnVudGltZV9hY3RpdmVfcnVuX2NvdW50GA0gASgNIiQKEkNoYXRQcmVwYXJlUmVxdWVzdBIOCgZyZWFzb24YASABKAkiWAoUQ2hhdFN1YnNjcmliZVJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhEKCWFmdGVyX3NlcRgCIAEoAxIUCgxzdHJlYW1fZXBvY2gYAyABKAki2QEKD0NoYXRSdW5BY3Rpdml0eRIOCgZydW5faWQYASABKAkSDQoFc3RhdGUYAiABKAkSEwoLc3RhcnRlZF9zZXEYAyABKAMSFQoNdXBkYXRlZF9hdF9tcxgEIAEoAxITCgt0b29sX3N0YXR1cxgFIAEoCRIhChl0b29sX3N0YXR1c19pc19jb21wYWN0aW9uGAYgASgIEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAcgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgIIAEoCRIPCgd3b3JrZGlyGAkgASgJIpQBCg9DaGF0UnVuU25hcHNob3QSDgoGcnVuX2lkGAEgASgJEhAKCHJldmlzaW9uGAIgASgDEhQKDGVudHJpZXNfanNvbhgDIAEoCRITCgt0b29sX3N0YXR1cxgEIAEoCRIhChl0b29sX3N0YXR1c19pc19jb21wYWN0aW9uGAUgASgIEhEKCWFzX29mX3NlcRgGIAEoAyLuAQoTQ2hhdFN1YnNjcmliZVJlc3VsdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFAoMc3RyZWFtX2Vwb2NoGAIgASgJEhIKCmxhdGVzdF9zZXEYAyABKAMSDQoFcmVzZXQYBCABKAgSNwoIYWN0aXZpdHkYBSABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0UnVuQWN0aXZpdHkSNwoIc25hcHNob3QYBiABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0UnVuU25hcHNob3QSEwoLZXZlbnRzX2pzb24YByADKAwiMQoWQ2hhdFVuc3Vic2NyaWJlUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiFwoVQ2hhdEFjdGl2aXRpZXNSZXF1ZXN0IlwKFENoYXRBY3Rpdml0aWVzUmVzdWx0EkQKFXJ1bm5pbmdfY29udmVyc2F0aW9ucxgBIAMoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRSdW5BY3Rpdml0eSJNCg9DaGF0U3RyZWFtRXZlbnQSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEgsKA3NlcRgCIAEoAxIUCgxwYXlsb2FkX2pzb24YAyABKAwiZQoTQ2hhdENvbW1hbmRBY2NlcHRlZBIOCgZydW5faWQYASABKAkSFwoPY29udmVyc2F0aW9uX2lkGAIgASgJEhQKDGFjY2VwdGVkX3NlcRgDIAEoAxIPCgdkZWR1cGVkGAQgASgIIosBChFDaGF0Q29tbWFuZFVwZGF0ZRIOCgZydW5faWQYASABKAkSGQoRY2xpZW50X3JlcXVlc3RfaWQYAiABKAkSFwoPY29udmVyc2F0aW9uX2lkGAMgASgJEg0KBXBoYXNlGAQgASgJEhIKCmVycm9yX2NvZGUYBSABKAkSDwoHbWVzc2FnZRgGIAEoCSIwChVDaGF0U3Vic2NyaXB0aW9uUmVzZXQSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJIkcKEENoYXRDYW5jZWxSZXN1bHQSCgoCb2sYASABKAgSDgoGcnVuX2lkGAIgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgDIAEoCSKfAQoRQ2hhdEFjdGl2aXR5RXZlbnQSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg4KBnJ1bl9pZBgCIAEoCRIZChFjbGllbnRfcmVxdWVzdF9pZBgDIAEoCRIPCgdydW5uaW5nGAQgASgIEg0KBXN0YXRlGAUgASgJEg8KB3dvcmtkaXIYBiABKAkSFQoNdXBkYXRlZF9hdF9tcxgHIAEoAyIsChlXb3Jrc3BhY2VTdWJzY3JpYmVSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkiLgobV29ya3NwYWNlVW5zdWJzY3JpYmVSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkqWQoKQ2xpZW50Um9sZRIbChdDTElFTlRfUk9MRV9VTlNQRUNJRklFRBAAEhcKE0NMSUVOVF9ST0xFX0JST1dTRVIQARIVChFDTElFTlRfUk9MRV9BR0VOVBACQkBaPmdpdGh1Yi5jb20vbGl2ZWFnZW50L2FnZW50LWdhdGV3YXkvaW50ZXJuYWwvcHJvdG8vdjI7Z2F0ZXdheXYyYgZwcm90bzM", [file_proto_v1_gateway]); + +/** + * ClientHello 是所有 v2 连接的第一帧。 + * + * @generated from message liveagent.gateway.v2.ClientHello + */ +export type ClientHello = Message<"liveagent.gateway.v2.ClientHello"> & { + /** + * 协议版本,当前恒为 2;未知版本被服务端拒绝。 + * + * @generated from field: uint32 protocol_version = 1; + */ + protocolVersion: number; + + /** + * @generated from field: liveagent.gateway.v2.ClientRole role = 2; + */ + role: ClientRole; + + /** + * 网关访问令牌,服务端做常量时间比较。 + * + * @generated from field: string token = 3; + */ + token: string; + + /** + * 以下字段仅 CLIENT_ROLE_AGENT 使用(对应 v1 gRPC AuthRequest)。 + * + * @generated from field: string agent_id = 4; + */ + agentId: string; + + /** + * @generated from field: string agent_version = 5; + */ + agentVersion: string; + + /** + * 客户端标识(如 "webui" / "desktop"),仅用于观测与日志。 + * + * @generated from field: string client_name = 6; + */ + clientName: string; + + /** + * @generated from field: string client_version = 7; + */ + clientVersion: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ClientHello. + * Use `create(ClientHelloSchema)` to create a new message. + */ +export const ClientHelloSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 0); + +/** + * ServerHello 是服务端对 ClientHello 的应答;ok=false 时随即关闭连接。 + * + * @generated from message liveagent.gateway.v2.ServerHello + */ +export type ServerHello = Message<"liveagent.gateway.v2.ServerHello"> & { + /** + * @generated from field: bool ok = 1; + */ + ok: boolean; + + /** + * @generated from field: string message = 2; + */ + message: string; + + /** + * 仅 agent 角色返回(对应 v1 gRPC AuthResponse.session_id)。 + * + * @generated from field: string session_id = 3; + */ + sessionId: string; + + /** + * 服务端 Unix 秒时间戳,供客户端校准。 + * + * @generated from field: int64 server_time = 4; + */ + serverTime: bigint; + + /** + * 服务端心跳周期与消息大小上限,客户端应据此配置本地看门狗与分片。 + * + * @generated from field: uint32 heartbeat_period_seconds = 5; + */ + heartbeatPeriodSeconds: number; + + /** + * @generated from field: uint64 max_message_bytes = 6; + */ + maxMessageBytes: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ServerHello. + * Use `create(ServerHelloSchema)` to create a new message. + */ +export const ServerHelloSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 1); + +/** + * PingFrame / PongFrame 是应用层心跳:WS 控制帧 ping 探测网络栈,本帧探测页面 JS/事件循环存活。 + * + * @generated from message liveagent.gateway.v2.PingFrame + */ +export type PingFrame = Message<"liveagent.gateway.v2.PingFrame"> & { + /** + * @generated from field: int64 timestamp = 1; + */ + timestamp: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.PingFrame. + * Use `create(PingFrameSchema)` to create a new message. + */ +export const PingFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 2); + +/** + * @generated from message liveagent.gateway.v2.PongFrame + */ +export type PongFrame = Message<"liveagent.gateway.v2.PongFrame"> & { + /** + * @generated from field: int64 timestamp = 1; + */ + timestamp: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.PongFrame. + * Use `create(PongFrameSchema)` to create a new message. + */ +export const PongFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 3); + +/** + * AckResult 是本地操作的通用确认应答(对应 v1 的 {"ok":true})。 + * + * @generated from message liveagent.gateway.v2.AckResult + */ +export type AckResult = Message<"liveagent.gateway.v2.AckResult"> & { + /** + * @generated from field: bool ok = 1; + */ + ok: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.AckResult. + * Use `create(AckResultSchema)` to create a new message. + */ +export const AckResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 4); + +/** + * WebClientFrame 为浏览器 → 网关方向的帧。除 agent_request 直通臂外,其余臂均为 + * 网关本地操作(由网关自身状态应答,不经桌面端往返)。 + * + * @generated from message liveagent.gateway.v2.WebClientFrame + */ +export type WebClientFrame = Message<"liveagent.gateway.v2.WebClientFrame"> & { + /** + * 请求关联 id,客户端生成、响应帧回携;广播事件与心跳帧为空。 + * + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * @generated from oneof liveagent.gateway.v2.WebClientFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ClientHello hello = 2; + */ + value: ClientHello; + case: "hello"; + } | { + /** + * 直通请求:网关校验白名单与限额后转发桌面端(request_id 按连接命名空间化防冲突)。 + * + * @generated from field: liveagent.gateway.v1.GatewayEnvelope agent_request = 3; + */ + value: GatewayEnvelope; + case: "agentRequest"; + } | { + /** + * @generated from field: liveagent.gateway.v2.StatusGetRequest status_get = 4; + */ + value: StatusGetRequest; + case: "statusGet"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ChatCommandRequest chat_command = 5; + */ + value: ChatCommandRequest; + case: "chatCommand"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatPrepareRequest chat_prepare = 6; + */ + value: ChatPrepareRequest; + case: "chatPrepare"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatSubscribeRequest chat_subscribe = 7; + */ + value: ChatSubscribeRequest; + case: "chatSubscribe"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatUnsubscribeRequest chat_unsubscribe = 8; + */ + value: ChatUnsubscribeRequest; + case: "chatUnsubscribe"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatActivitiesRequest chat_activities = 9; + */ + value: ChatActivitiesRequest; + case: "chatActivities"; + } | { + /** + * @generated from field: liveagent.gateway.v2.WorkspaceSubscribeRequest workspace_subscribe = 10; + */ + value: WorkspaceSubscribeRequest; + case: "workspaceSubscribe"; + } | { + /** + * @generated from field: liveagent.gateway.v2.WorkspaceUnsubscribeRequest workspace_unsubscribe = 11; + */ + value: WorkspaceUnsubscribeRequest; + case: "workspaceUnsubscribe"; + } | { + /** + * @generated from field: liveagent.gateway.v2.PongFrame pong = 12; + */ + value: PongFrame; + case: "pong"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.WebClientFrame. + * Use `create(WebClientFrameSchema)` to create a new message. + */ +export const WebClientFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 5); + +/** + * WebServerFrame 为网关 → 浏览器方向的帧。 + * + * @generated from message liveagent.gateway.v2.WebServerFrame + */ +export type WebServerFrame = Message<"liveagent.gateway.v2.WebServerFrame"> & { + /** + * 关联响应回填请求 id;服务端主动推送(广播/心跳)时为空。 + * + * @generated from field: string request_id = 1; + */ + requestId: string; + + /** + * @generated from oneof liveagent.gateway.v2.WebServerFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ServerHello hello = 2; + */ + value: ServerHello; + case: "hello"; + } | { + /** + * 直通响应:桌面端返回的 v1 信封(含 error=99 错误臂)。 + * + * @generated from field: liveagent.gateway.v1.AgentEnvelope agent_response = 3; + */ + value: AgentEnvelope; + case: "agentResponse"; + } | { + /** + * 网关本地错误(鉴权、校验、离线等;对应 v1 的 type:"error" 信封)。 + * + * @generated from field: liveagent.gateway.v1.ErrorResponse local_error = 4; + */ + value: ErrorResponse; + case: "localError"; + } | { + /** + * @generated from field: liveagent.gateway.v2.PingFrame ping = 5; + */ + value: PingFrame; + case: "ping"; + } | { + /** + * status_get / chat_prepare 的响应与 status 广播共用,新旧客户端共享一个状态归一化器。 + * + * @generated from field: liveagent.gateway.v2.StatusEvent status = 6; + */ + value: StatusEvent; + case: "status"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatSubscribeResult chat_subscribed = 7; + */ + value: ChatSubscribeResult; + case: "chatSubscribed"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatCommandAccepted chat_accepted = 8; + */ + value: ChatCommandAccepted; + case: "chatAccepted"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatActivitiesResult chat_activities = 9; + */ + value: ChatActivitiesResult; + case: "chatActivities"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatStreamEvent chat_event = 10; + */ + value: ChatStreamEvent; + case: "chatEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatCommandUpdate chat_command_update = 11; + */ + value: ChatCommandUpdate; + case: "chatCommandUpdate"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatSubscriptionReset chat_subscription_reset = 12; + */ + value: ChatSubscriptionReset; + case: "chatSubscriptionReset"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatActivityEvent chat_activity = 13; + */ + value: ChatActivityEvent; + case: "chatActivity"; + } | { + /** + * @generated from field: liveagent.gateway.v2.AckResult ack = 14; + */ + value: AckResult; + case: "ack"; + } | { + /** + * @generated from field: liveagent.gateway.v2.ChatCancelResult chat_cancelled = 15; + */ + value: ChatCancelResult; + case: "chatCancelled"; + } | { + /** + * 广播事件:session 层吐出的 v1 seam 消息零塑形直转。 + * + * @generated from field: liveagent.gateway.v1.HistorySyncEvent history_event = 20; + */ + value: HistorySyncEvent; + case: "historyEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SettingsSyncEvent settings_event = 21; + */ + value: SettingsSyncEvent; + case: "settingsEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TerminalEvent terminal_event = 22; + */ + value: TerminalEvent; + case: "terminalEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v1.SftpEvent sftp_event = 23; + */ + value: SftpEvent; + case: "sftpEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ChatQueueEvent chat_queue_event = 24; + */ + value: ChatQueueEvent; + case: "chatQueueEvent"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TunnelStateSnapshot tunnel_state = 25; + */ + value: TunnelStateSnapshot; + case: "tunnelState"; + } | { + /** + * @generated from field: liveagent.gateway.v1.ManagedProcessSnapshot process_state = 26; + */ + value: ManagedProcessSnapshot; + case: "processState"; + } | { + /** + * @generated from field: liveagent.gateway.v1.WorkspaceActivityEvent workspace_activity = 27; + */ + value: WorkspaceActivityEvent; + case: "workspaceActivity"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.WebServerFrame. + * Use `create(WebServerFrameSchema)` to create a new message. + */ +export const WebServerFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 6); + +/** + * AgentClientFrame 为桌面端 → 网关方向的帧。 + * + * @generated from message liveagent.gateway.v2.AgentClientFrame + */ +export type AgentClientFrame = Message<"liveagent.gateway.v2.AgentClientFrame"> & { + /** + * @generated from oneof liveagent.gateway.v2.AgentClientFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ClientHello hello = 1; + */ + value: ClientHello; + case: "hello"; + } | { + /** + * @generated from field: liveagent.gateway.v1.AgentEnvelope envelope = 2; + */ + value: AgentEnvelope; + case: "envelope"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.AgentClientFrame. + * Use `create(AgentClientFrameSchema)` to create a new message. + */ +export const AgentClientFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 7); + +/** + * AgentServerFrame 为网关 → 桌面端方向的帧。 + * + * @generated from message liveagent.gateway.v2.AgentServerFrame + */ +export type AgentServerFrame = Message<"liveagent.gateway.v2.AgentServerFrame"> & { + /** + * @generated from oneof liveagent.gateway.v2.AgentServerFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ServerHello hello = 1; + */ + value: ServerHello; + case: "hello"; + } | { + /** + * @generated from field: liveagent.gateway.v1.GatewayEnvelope envelope = 2; + */ + value: GatewayEnvelope; + case: "envelope"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.AgentServerFrame. + * Use `create(AgentServerFrameSchema)` to create a new message. + */ +export const AgentServerFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 8); + +/** + * TerminalClientFrame 为客户端(浏览器或桌面端)→ 网关方向的帧。 + * + * @generated from message liveagent.gateway.v2.TerminalClientFrame + */ +export type TerminalClientFrame = Message<"liveagent.gateway.v2.TerminalClientFrame"> & { + /** + * @generated from oneof liveagent.gateway.v2.TerminalClientFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ClientHello hello = 1; + */ + value: ClientHello; + case: "hello"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TerminalStreamFrame frame = 2; + */ + value: TerminalStreamFrame; + case: "frame"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalClientFrame. + * Use `create(TerminalClientFrameSchema)` to create a new message. + */ +export const TerminalClientFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 9); + +/** + * TerminalServerFrame 为网关 → 客户端方向的帧。 + * + * @generated from message liveagent.gateway.v2.TerminalServerFrame + */ +export type TerminalServerFrame = Message<"liveagent.gateway.v2.TerminalServerFrame"> & { + /** + * @generated from oneof liveagent.gateway.v2.TerminalServerFrame.payload + */ + payload: { + /** + * @generated from field: liveagent.gateway.v2.ServerHello hello = 1; + */ + value: ServerHello; + case: "hello"; + } | { + /** + * @generated from field: liveagent.gateway.v1.TerminalStreamFrame frame = 2; + */ + value: TerminalStreamFrame; + case: "frame"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message liveagent.gateway.v2.TerminalServerFrame. + * Use `create(TerminalServerFrameSchema)` to create a new message. + */ +export const TerminalServerFrameSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 10); + +/** + * StatusGetRequest 请求网关侧运行状态快照(对应 v1 "status.get")。 + * + * @generated from message liveagent.gateway.v2.StatusGetRequest + */ +export type StatusGetRequest = Message<"liveagent.gateway.v2.StatusGetRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v2.StatusGetRequest. + * Use `create(StatusGetRequestSchema)` to create a new message. + */ +export const StatusGetRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 11); + +/** + * StatusEvent 镜像 session.Status 的 JSON 形状(字段一一对应)。 + * + * @generated from message liveagent.gateway.v2.StatusEvent + */ +export type StatusEvent = Message<"liveagent.gateway.v2.StatusEvent"> & { + /** + * @generated from field: bool online = 1; + */ + online: boolean; + + /** + * @generated from field: bool agent_ready = 2; + */ + agentReady: boolean; + + /** + * @generated from field: bool chat_runtime_ready = 3; + */ + chatRuntimeReady: boolean; + + /** + * @generated from field: string agent_id = 4; + */ + agentId: string; + + /** + * @generated from field: string agent_version = 5; + */ + agentVersion: string; + + /** + * @generated from field: string session_id = 6; + */ + sessionId: string; + + /** + * @generated from field: int64 connected_since = 7; + */ + connectedSince: bigint; + + /** + * @generated from field: int64 last_heartbeat = 8; + */ + lastHeartbeat: bigint; + + /** + * @generated from field: string runtime_state = 9; + */ + runtimeState: string; + + /** + * @generated from field: int64 runtime_last_heartbeat = 10; + */ + runtimeLastHeartbeat: bigint; + + /** + * @generated from field: string runtime_worker_id = 11; + */ + runtimeWorkerId: string; + + /** + * @generated from field: bool runtime_visible = 12; + */ + runtimeVisible: boolean; + + /** + * @generated from field: uint32 runtime_active_run_count = 13; + */ + runtimeActiveRunCount: number; +}; + +/** + * Describes the message liveagent.gateway.v2.StatusEvent. + * Use `create(StatusEventSchema)` to create a new message. + */ +export const StatusEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 12); + +/** + * ChatPrepareRequest 唤醒/探活桌面端 chat 运行时(对应 v1 "chat.prepare");响应为 StatusEvent。 + * + * @generated from message liveagent.gateway.v2.ChatPrepareRequest + */ +export type ChatPrepareRequest = Message<"liveagent.gateway.v2.ChatPrepareRequest"> & { + /** + * @generated from field: string reason = 1; + */ + reason: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatPrepareRequest. + * Use `create(ChatPrepareRequestSchema)` to create a new message. + */ +export const ChatPrepareRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 13); + +/** + * ChatSubscribeRequest 订阅会话事件流(对应 v1 "chat.subscribe")。after_seq + stream_epoch + * 支持断线重放:epoch 不匹配或序号超界时服务端置 reset 并从头回放。 + * + * @generated from message liveagent.gateway.v2.ChatSubscribeRequest + */ +export type ChatSubscribeRequest = Message<"liveagent.gateway.v2.ChatSubscribeRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: int64 after_seq = 2; + */ + afterSeq: bigint; + + /** + * @generated from field: string stream_epoch = 3; + */ + streamEpoch: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatSubscribeRequest. + * Use `create(ChatSubscribeRequestSchema)` to create a new message. + */ +export const ChatSubscribeRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 14); + +/** + * ChatRunActivity 镜像 session.RunActivity 的 JSON 形状。 + * + * @generated from message liveagent.gateway.v2.ChatRunActivity + */ +export type ChatRunActivity = Message<"liveagent.gateway.v2.ChatRunActivity"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string state = 2; + */ + state: string; + + /** + * @generated from field: int64 started_seq = 3; + */ + startedSeq: bigint; + + /** + * Unix 毫秒时间戳。 + * + * @generated from field: int64 updated_at_ms = 4; + */ + updatedAtMs: bigint; + + /** + * @generated from field: string tool_status = 5; + */ + toolStatus: string; + + /** + * @generated from field: bool tool_status_is_compaction = 6; + */ + toolStatusIsCompaction: boolean; + + /** + * @generated from field: string client_request_id = 7; + */ + clientRequestId: string; + + /** + * 以下两个字段仅在 chat_activities 列表中填充。 + * + * @generated from field: string conversation_id = 8; + */ + conversationId: string; + + /** + * @generated from field: string workdir = 9; + */ + workdir: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatRunActivity. + * Use `create(ChatRunActivitySchema)` to create a new message. + */ +export const ChatRunActivitySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 15); + +/** + * ChatRunSnapshot 镜像 session.RunSnapshot 的 JSON 形状。 + * + * @generated from message liveagent.gateway.v2.ChatRunSnapshot + */ +export type ChatRunSnapshot = Message<"liveagent.gateway.v2.ChatRunSnapshot"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: int64 revision = 2; + */ + revision: bigint; + + /** + * 桌面端渲染快照,内容为动态 JSON(与 v1 一致按字符串携带,不建模)。 + * + * @generated from field: string entries_json = 3; + */ + entriesJson: string; + + /** + * @generated from field: string tool_status = 4; + */ + toolStatus: string; + + /** + * @generated from field: bool tool_status_is_compaction = 5; + */ + toolStatusIsCompaction: boolean; + + /** + * @generated from field: int64 as_of_seq = 6; + */ + asOfSeq: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatRunSnapshot. + * Use `create(ChatRunSnapshotSchema)` to create a new message. + */ +export const ChatRunSnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 16); + +/** + * ChatSubscribeResult 是 chat_subscribe 的响应。 + * + * @generated from message liveagent.gateway.v2.ChatSubscribeResult + */ +export type ChatSubscribeResult = Message<"liveagent.gateway.v2.ChatSubscribeResult"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string stream_epoch = 2; + */ + streamEpoch: string; + + /** + * @generated from field: int64 latest_seq = 3; + */ + latestSeq: bigint; + + /** + * @generated from field: bool reset = 4; + */ + reset: boolean; + + /** + * @generated from field: liveagent.gateway.v2.ChatRunActivity activity = 5; + */ + activity?: ChatRunActivity | undefined; + + /** + * @generated from field: liveagent.gateway.v2.ChatRunSnapshot snapshot = 6; + */ + snapshot?: ChatRunSnapshot | undefined; + + /** + * 回放的事件序列。载荷由 chatwire 塑形为深度动态 JSON,与 v1 一致按字节携带、不 proto 化 + * (重建模会分叉 chatwire 且对压缩后的二进制帧无实际收益)。 + * + * @generated from field: repeated bytes events_json = 7; + */ + eventsJson: Uint8Array[]; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatSubscribeResult. + * Use `create(ChatSubscribeResultSchema)` to create a new message. + */ +export const ChatSubscribeResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 17); + +/** + * ChatUnsubscribeRequest 取消订阅(对应 v1 "chat.unsubscribe");响应 AckResult。 + * + * @generated from message liveagent.gateway.v2.ChatUnsubscribeRequest + */ +export type ChatUnsubscribeRequest = Message<"liveagent.gateway.v2.ChatUnsubscribeRequest"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatUnsubscribeRequest. + * Use `create(ChatUnsubscribeRequestSchema)` to create a new message. + */ +export const ChatUnsubscribeRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 18); + +/** + * ChatActivitiesRequest 查询运行中会话(对应 v1 "chat.activities");仅由网关状态应答,桌面端离线时亦可用。 + * + * @generated from message liveagent.gateway.v2.ChatActivitiesRequest + */ +export type ChatActivitiesRequest = Message<"liveagent.gateway.v2.ChatActivitiesRequest"> & { +}; + +/** + * Describes the message liveagent.gateway.v2.ChatActivitiesRequest. + * Use `create(ChatActivitiesRequestSchema)` to create a new message. + */ +export const ChatActivitiesRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 19); + +/** + * @generated from message liveagent.gateway.v2.ChatActivitiesResult + */ +export type ChatActivitiesResult = Message<"liveagent.gateway.v2.ChatActivitiesResult"> & { + /** + * @generated from field: repeated liveagent.gateway.v2.ChatRunActivity running_conversations = 1; + */ + runningConversations: ChatRunActivity[]; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatActivitiesResult. + * Use `create(ChatActivitiesResultSchema)` to create a new message. + */ +export const ChatActivitiesResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 20); + +/** + * ChatStreamEvent 是订阅后推送的单条会话事件。 + * + * @generated from message liveagent.gateway.v2.ChatStreamEvent + */ +export type ChatStreamEvent = Message<"liveagent.gateway.v2.ChatStreamEvent"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * 事件序号(与 payload_json 内的 seq 一致,便于不解析载荷即可去重)。 + * + * @generated from field: int64 seq = 2; + */ + seq: bigint; + + /** + * @generated from field: bytes payload_json = 3; + */ + payloadJson: Uint8Array; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatStreamEvent. + * Use `create(ChatStreamEventSchema)` to create a new message. + */ +export const ChatStreamEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 21); + +/** + * ChatCommandAccepted 是 chat_command 提交被接受的响应(对应 v1 chat.command 的应答形状)。 + * + * @generated from message liveagent.gateway.v2.ChatCommandAccepted + */ +export type ChatCommandAccepted = Message<"liveagent.gateway.v2.ChatCommandAccepted"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string conversation_id = 2; + */ + conversationId: string; + + /** + * @generated from field: int64 accepted_seq = 3; + */ + acceptedSeq: bigint; + + /** + * @generated from field: bool deduped = 4; + */ + deduped: boolean; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatCommandAccepted. + * Use `create(ChatCommandAcceptedSchema)` to create a new message. + */ +export const ChatCommandAcceptedSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 22); + +/** + * ChatCommandUpdate 推送命令的前置阶段结果(bound / queued_in_gui / failed),镜像 session.ChatCommandUpdate。 + * + * @generated from message liveagent.gateway.v2.ChatCommandUpdate + */ +export type ChatCommandUpdate = Message<"liveagent.gateway.v2.ChatCommandUpdate"> & { + /** + * @generated from field: string run_id = 1; + */ + runId: string; + + /** + * @generated from field: string client_request_id = 2; + */ + clientRequestId: string; + + /** + * @generated from field: string conversation_id = 3; + */ + conversationId: string; + + /** + * @generated from field: string phase = 4; + */ + phase: string; + + /** + * @generated from field: string error_code = 5; + */ + errorCode: string; + + /** + * @generated from field: string message = 6; + */ + message: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatCommandUpdate. + * Use `create(ChatCommandUpdateSchema)` to create a new message. + */ +export const ChatCommandUpdateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 23); + +/** + * ChatSubscriptionReset 通知客户端某会话流已被限流丢弃,需重新订阅(after_seq 断点续传)。 + * + * @generated from message liveagent.gateway.v2.ChatSubscriptionReset + */ +export type ChatSubscriptionReset = Message<"liveagent.gateway.v2.ChatSubscriptionReset"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatSubscriptionReset. + * Use `create(ChatSubscriptionResetSchema)` to create a new message. + */ +export const ChatSubscriptionResetSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 24); + +/** + * ChatCancelResult 是 chat.cancel 的响应。 + * + * @generated from message liveagent.gateway.v2.ChatCancelResult + */ +export type ChatCancelResult = Message<"liveagent.gateway.v2.ChatCancelResult"> & { + /** + * @generated from field: bool ok = 1; + */ + ok: boolean; + + /** + * @generated from field: string run_id = 2; + */ + runId: string; + + /** + * @generated from field: string conversation_id = 3; + */ + conversationId: string; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatCancelResult. + * Use `create(ChatCancelResultSchema)` to create a new message. + */ +export const ChatCancelResultSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 25); + +/** + * ChatActivityEvent 广播会话活动状态变化,镜像 session.ConversationActivityEvent。 + * + * @generated from message liveagent.gateway.v2.ChatActivityEvent + */ +export type ChatActivityEvent = Message<"liveagent.gateway.v2.ChatActivityEvent"> & { + /** + * @generated from field: string conversation_id = 1; + */ + conversationId: string; + + /** + * @generated from field: string run_id = 2; + */ + runId: string; + + /** + * @generated from field: string client_request_id = 3; + */ + clientRequestId: string; + + /** + * @generated from field: bool running = 4; + */ + running: boolean; + + /** + * @generated from field: string state = 5; + */ + state: string; + + /** + * @generated from field: string workdir = 6; + */ + workdir: string; + + /** + * @generated from field: int64 updated_at_ms = 7; + */ + updatedAtMs: bigint; +}; + +/** + * Describes the message liveagent.gateway.v2.ChatActivityEvent. + * Use `create(ChatActivityEventSchema)` to create a new message. + */ +export const ChatActivityEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 26); + +/** + * WorkspaceSubscribeRequest 订阅工作区活动(对应 v1 "workspace.subscribe");响应 AckResult,事件经 workspace_activity 臂广播。 + * + * @generated from message liveagent.gateway.v2.WorkspaceSubscribeRequest + */ +export type WorkspaceSubscribeRequest = Message<"liveagent.gateway.v2.WorkspaceSubscribeRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; +}; + +/** + * Describes the message liveagent.gateway.v2.WorkspaceSubscribeRequest. + * Use `create(WorkspaceSubscribeRequestSchema)` to create a new message. + */ +export const WorkspaceSubscribeRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 27); + +/** + * WorkspaceUnsubscribeRequest 取消订阅;响应 AckResult。 + * + * @generated from message liveagent.gateway.v2.WorkspaceUnsubscribeRequest + */ +export type WorkspaceUnsubscribeRequest = Message<"liveagent.gateway.v2.WorkspaceUnsubscribeRequest"> & { + /** + * @generated from field: string workdir = 1; + */ + workdir: string; +}; + +/** + * Describes the message liveagent.gateway.v2.WorkspaceUnsubscribeRequest. + * Use `create(WorkspaceUnsubscribeRequestSchema)` to create a new message. + */ +export const WorkspaceUnsubscribeRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_proto_v2_gateway_ws, 28); + +/** + * ClientRole 区分 /ws/v2/terminal 上连接的所属端(该链路两端共用一条路径,靠 hello.role 区分)。 + * + * @generated from enum liveagent.gateway.v2.ClientRole + */ +export enum ClientRole { + /** + * @generated from enum value: CLIENT_ROLE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: CLIENT_ROLE_BROWSER = 1; + */ + BROWSER = 1, + + /** + * @generated from enum value: CLIENT_ROLE_AGENT = 2; + */ + AGENT = 2, +} + +/** + * Describes the enum liveagent.gateway.v2.ClientRole. + */ +export const ClientRoleSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_proto_v2_gateway_ws, 0); + diff --git a/crates/agent-gateway/web/src/lib/terminal/gatewayTerminalStreamClient.ts b/crates/agent-gateway/web/src/lib/terminal/gatewayTerminalStreamClient.ts index 0afabe4bd..2ef4d458a 100644 --- a/crates/agent-gateway/web/src/lib/terminal/gatewayTerminalStreamClient.ts +++ b/crates/agent-gateway/web/src/lib/terminal/gatewayTerminalStreamClient.ts @@ -1,3 +1,10 @@ +import type { TerminalWireHeader } from "@/lib/gatewaySocketV2/adapters"; +import { + decodeTerminalServerFrame, + encodeTerminalHelloFrame, + encodeTerminalStreamFrame, + GATEWAY_V2_SUBPROTOCOL, +} from "@/lib/gatewaySocketV2/adapters"; import type { TerminalSession, TerminalStreamChunk, @@ -7,38 +14,15 @@ import type { TerminalStreamSnapshot, } from "./types"; -const FRAME_VERSION = 1; const INPUT_FLUSH_BYTES = 4 * 1024; const INPUT_FLUSH_MS = 8; const INPUT_RETRY_MS = 25; const INPUT_HIGH_WATER_BYTES = 256 * 1024; const INPUT_LOW_WATER_BYTES = 128 * 1024; const ATTACH_RETRY_MS = 250; -const KIND_BYTES: Record = { - attach: 1, - input: 2, - resize: 3, - detach: 4, - output: 5, - snapshot: 6, - error: 7, -}; -type TerminalFrameHeader = { - kind?: string; - streamId?: string; - sessionId?: string; - projectPathKey?: string; - seq?: number; - startOffset?: number; - endOffset?: number; - cols?: number; - rows?: number; - maxBytes?: number; - truncated?: boolean; - error?: string; - session?: unknown; -}; +// 帧头形状沿用旧自定义帧的命名;v2 下由适配层映射到 TerminalStreamFrame。 +type TerminalFrameHeader = TerminalWireHeader; type PendingAttach = { handle: GatewayTerminalStreamHandle; @@ -48,22 +32,16 @@ type PendingAttach = { retryTimerId: ReturnType | null; }; -function terminalStreamUrls() { +function terminalStreamUrl() { const origin = terminalRuntimeOrigin(); if (!origin) { throw new Error("Gateway terminal stream origin is unavailable"); } - return [ - buildTerminalStreamUrl(origin, "/ws/terminal"), - buildTerminalStreamUrl(origin, "/ws", "terminal=1"), - ].filter((url, index, urls) => urls.indexOf(url) === index); -} - -function buildTerminalStreamUrl(origin: string, pathname: string, search = "") { + // v2 终端数据面唯一端点(旧 /ws/terminal 与 /ws?terminal=1 回退已淘汰)。 const url = new URL(origin); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - url.pathname = pathname; - url.search = search; + url.pathname = "/ws/v2/terminal"; + url.search = ""; url.hash = ""; return url.toString(); } @@ -109,47 +87,6 @@ function isRetryableAttachError(message: string) { ); } -function encodeFrame( - header: TerminalFrameHeader, - data: Uint8Array = new Uint8Array(), -) { - const kind = header.kind?.trim() ?? ""; - const headerBytes = new TextEncoder().encode(JSON.stringify(header)); - if (headerBytes.byteLength > 0xffff) { - throw new Error("Terminal stream header is too large"); - } - const payload = new Uint8Array(4 + headerBytes.byteLength + data.byteLength); - payload[0] = FRAME_VERSION; - payload[1] = KIND_BYTES[kind] ?? 0; - new DataView(payload.buffer).setUint16(2, headerBytes.byteLength, false); - payload.set(headerBytes, 4); - payload.set(data, 4 + headerBytes.byteLength); - return payload; -} - -function decodeFrame(payload: ArrayBuffer) { - const bytes = new Uint8Array(payload); - if (bytes.byteLength < 4 || bytes[0] !== FRAME_VERSION) { - throw new Error("Invalid terminal stream frame"); - } - const headerLength = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16( - 2, - false, - ); - const headerStart = 4; - const headerEnd = headerStart + headerLength; - if (bytes.byteLength < headerEnd) { - throw new Error("Truncated terminal stream frame"); - } - const header = JSON.parse( - new TextDecoder().decode(bytes.subarray(headerStart, headerEnd)), - ) as TerminalFrameHeader; - return { - header, - data: bytes.slice(headerEnd), - }; -} - function normalizeSession(input: unknown): TerminalSession { const raw = (input ?? {}) as Record; const ssh = (raw.ssh ?? null) as Record | null; @@ -490,7 +427,9 @@ export class BrowserGatewayTerminalStreamClient implements TerminalStreamClient if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { throw new Error("Terminal stream is not connected"); } - this.socket.send(encodeFrame(header, data)); + this.socket.send( + encodeTerminalStreamFrame(header, data ? new Uint8Array(data) : new Uint8Array()), + ); } detach(streamId: string, session: TerminalSession, handle: GatewayTerminalStreamHandle) { @@ -499,7 +438,7 @@ export class BrowserGatewayTerminalStreamClient implements TerminalStreamClient if (this.socket?.readyState === WebSocket.OPEN) { try { this.socket.send( - encodeFrame({ + encodeTerminalStreamFrame({ kind: "detach", streamId, sessionId: sessionStillAttached ? undefined : session.id, @@ -545,7 +484,6 @@ export class BrowserGatewayTerminalStreamClient implements TerminalStreamClient if (this.socket?.readyState === WebSocket.OPEN) return; if (this.connectPromise) return this.connectPromise; this.connectPromise = new Promise((resolve, reject) => { - const urls = terminalStreamUrls(); let settled = false; let timeoutId: ReturnType | null = null; const clearAttemptTimeout = () => { @@ -567,94 +505,84 @@ export class BrowserGatewayTerminalStreamClient implements TerminalStreamClient this.socket = socket; resolve(); }; - const tryUrl = (index: number, previousError?: Error) => { - const url = urls[index]; - if (!url) { - rejectOnce(previousError ?? new Error("Terminal stream connection failed")); + let url: string; + try { + url = terminalStreamUrl(); + } catch (error) { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + return; + } + // v2:二进制 protobuf 帧 + 子协议协商 + hello 鉴权握手。 + const socket = new WebSocket(url, GATEWAY_V2_SUBPROTOCOL); + socket.binaryType = "arraybuffer"; + const failAttempt = (error: Error) => { + if (settled) return; + clearAttemptTimeout(); + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + try { + socket.close(); + } catch { + // The browser may already have torn the socket down. + } + rejectOnce(error); + }; + timeoutId = setTimeout(() => { + failAttempt(new Error("Terminal stream connection timed out")); + }, 15_000); + socket.onopen = () => { + socket.send(encodeTerminalHelloFrame(this.token)); + }; + socket.onmessage = (event) => { + if (typeof event.data === "string") { + // v2 链路无文本帧;忽略。 return; } - const socket = new WebSocket(url); - socket.binaryType = "arraybuffer"; - let attemptFinished = false; - const failAttempt = (error: Error) => { - if (settled || attemptFinished) return; - attemptFinished = true; - clearAttemptTimeout(); - socket.onopen = null; - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - try { - socket.close(); - } catch { - // The browser may already have torn the socket down. - } - if (index + 1 < urls.length) { - tryUrl(index + 1, error); + const decoded = decodeTerminalServerFrame(event.data as ArrayBuffer); + if (!decoded) return; + if (decoded.kind === "hello") { + if (decoded.ok) { + resolveOnce(socket); } else { - rejectOnce(error); - } - }; - timeoutId = setTimeout(() => { - failAttempt(new Error("Terminal stream connection timed out")); - }, 15_000); - socket.onopen = () => { - socket.send(JSON.stringify({ type: "auth", token: this.token })); - }; - socket.onmessage = (event) => { - if (typeof event.data === "string") { - const payload = JSON.parse(event.data) as { type?: string; error?: string }; - if (payload.type === "ready") { - resolveOnce(socket); - } else if (payload.type === "error") { - failAttempt(new Error(payload.error || "Terminal stream auth failed")); - } - return; - } - this.handleBinaryMessage(event.data as ArrayBuffer); - }; - socket.onerror = () => { - failAttempt(new Error("Terminal stream connection failed")); - }; - socket.onclose = () => { - if (!settled) { - failAttempt(new Error("Terminal stream connection closed")); - return; + failAttempt(new Error(decoded.message || "Terminal stream auth failed")); } - if (this.socket === socket) { - this.socket = null; - this.handleSocketClosed(); - } - }; + return; + } + this.handleStreamFrame(decoded.header, decoded.data); + }; + socket.onerror = () => { + failAttempt(new Error("Terminal stream connection failed")); + }; + socket.onclose = () => { + if (!settled) { + failAttempt(new Error("Terminal stream connection closed")); + return; + } + if (this.socket === socket) { + this.socket = null; + this.handleSocketClosed(); + } }; - tryUrl(0); }).finally(() => { this.connectPromise = null; }); return this.connectPromise; } - private handleBinaryMessage(payload: ArrayBuffer) { - let frame: { header: TerminalFrameHeader; data: Uint8Array }; - try { - frame = decodeFrame(payload); - } catch { - return; - } - const kind = frame.header.kind ?? ""; + private handleStreamFrame(header: TerminalFrameHeader, data: Uint8Array) { + const kind = header.kind ?? ""; if (kind === "snapshot") { - this.resolveAttach(frame.header, frame.data); + this.resolveAttach(header, data); return; } if (kind === "output") { - this.emitOutput(frame.header, frame.data); + this.emitOutput(header, data); return; } if (kind === "error") { - this.rejectAttach( - frame.header.streamId ?? "", - frame.header.error || "Terminal stream failed", - ); + this.rejectAttach(header.streamId ?? "", header.error || "Terminal stream failed"); } } diff --git a/crates/agent-gateway/web/test/gateway-v2-adapters.test.mjs b/crates/agent-gateway/web/test/gateway-v2-adapters.test.mjs new file mode 100644 index 000000000..d109fb88c --- /dev/null +++ b/crates/agent-gateway/web/test/gateway-v2-adapters.test.mjs @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), +}); + +const adapters = loader.loadModule("src/lib/gatewaySocketV2/adapters.ts"); +const pb = loader.loadModule("@bufbuild/protobuf"); +const v2 = loader.loadModule("src/lib/proto/gen/proto/v2/gateway_ws_pb.ts"); + +const { decodeServerFrame, decodeServerFrameBinary, encodeRequestFrame } = adapters; + +function serverFrame(init) { + return pb.fromJson(v2.WebServerFrameSchema, init); +} + +function roundtrip(frame) { + return decodeServerFrameBinary(pb.toBinary(v2.WebServerFrameSchema, frame)); +} + +function decodeClientFrame(bytes) { + return pb.fromBinary(v2.WebClientFrameSchema, bytes); +} + +test("adapters convert int64/uint64 fields to Number at realistic maxima", () => { + // 毫秒时间戳(2100 年)与 MAX_SAFE_INTEGER 边界都必须无损转换。 + const year2100Ms = 4102444800000; + const maxSafe = Number.MAX_SAFE_INTEGER; + + const statusDecoded = decodeServerFrame( + roundtrip( + serverFrame({ + status: { + online: true, + connected_since: year2100Ms, + last_heartbeat: maxSafe, + runtime_last_heartbeat: year2100Ms, + runtime_state: "ready", + }, + }), + ), + { agentOnline: true }, + ); + assert.equal(statusDecoded.kind, "event"); + assert.equal(statusDecoded.payload.connected_since, year2100Ms); + assert.equal(statusDecoded.payload.last_heartbeat, maxSafe); + assert.equal(statusDecoded.payload.runtime_last_heartbeat, year2100Ms); + assert.equal(typeof statusDecoded.payload.connected_since, "number"); + + const activityDecoded = decodeServerFrame( + roundtrip( + serverFrame({ + chat_activity: { + conversation_id: "conversation-1", + running: true, + updated_at_ms: year2100Ms, + }, + }), + ), + { agentOnline: true }, + ); + assert.equal(activityDecoded.payload.updated_at, year2100Ms); + + // uint64 revision(tunnel_state)同样落在 Number 域。 + const tunnelDecoded = decodeServerFrame( + roundtrip( + serverFrame({ + tunnel_state: { revision: maxSafe, agent_online: true }, + }), + ), + { agentOnline: true }, + ); + assert.equal(tunnelDecoded.payload.revision, maxSafe); + assert.equal(typeof tunnelDecoded.payload.revision, "number"); +}); + +test("decodeServerFrame dispatches on the oneof arm", () => { + const ping = decodeServerFrame(roundtrip(serverFrame({ ping: { timestamp: 123 } })), { + agentOnline: false, + }); + assert.deepEqual(ping, { kind: "ping", timestamp: 123 }); + + const localError = decodeServerFrame( + roundtrip(serverFrame({ request_id: "req-1", local_error: { message: "agent offline" } })), + { agentOnline: false }, + ); + assert.deepEqual(localError, { kind: "error", requestId: "req-1", message: "agent offline" }); + + // status 臂:带 request_id 是响应,空 request_id 是广播。 + const statusResponse = decodeServerFrame( + roundtrip(serverFrame({ request_id: "req-2", status: { online: true } })), + { agentOnline: false }, + ); + assert.equal(statusResponse.kind, "response"); + const statusEvent = decodeServerFrame(roundtrip(serverFrame({ status: { online: true } })), { + agentOnline: false, + }); + assert.equal(statusEvent.kind, "event"); + assert.equal(statusEvent.type, "status.event"); + + const ack = decodeServerFrame( + roundtrip(serverFrame({ request_id: "req-3", ack: { ok: true } })), + { agentOnline: false }, + ); + assert.deepEqual(ack, { kind: "response", requestId: "req-3", payload: { ok: true } }); + + // agent_response 的 error 臂映射为 v1 同款错误。 + const agentError = decodeServerFrame( + roundtrip( + serverFrame({ + request_id: "req-4", + agent_response: { error: { code: 99, message: "boom" } }, + }), + ), + { agentOnline: false }, + ); + assert.deepEqual(agentError, { kind: "error", requestId: "req-4", message: "boom" }); + + // 空载荷帧被忽略。 + const empty = decodeServerFrame(pb.create(v2.WebServerFrameSchema, {}), { agentOnline: false }); + assert.equal(empty, null); +}); + +test("chat_event payload_json roundtrips to the identical v1 event object", () => { + const payload = { + type: "token", + conversation_id: "conversation-1", + run_id: "run-1", + seq: 42, + text: "你好 · emoji 🎯", + usage: { input: 10, output: 20 }, + }; + const decoded = decodeServerFrame( + roundtrip( + serverFrame({ + chat_event: { + conversation_id: "conversation-1", + seq: 42, + payload_json: Buffer.from(JSON.stringify(payload)).toString("base64"), + }, + }), + ), + { agentOnline: false }, + ); + assert.equal(decoded.kind, "event"); + assert.equal(decoded.type, "chat.event"); + assert.deepEqual(decoded.payload, payload); + + // chat_subscribed 的 events_json 逐条解析。 + const subscribed = decodeServerFrame( + roundtrip( + serverFrame({ + request_id: "req-1", + chat_subscribed: { + conversation_id: "conversation-1", + stream_epoch: "epoch-1", + latest_seq: 42, + events_json: [Buffer.from(JSON.stringify(payload)).toString("base64")], + }, + }), + ), + { agentOnline: false }, + ); + assert.equal(subscribed.kind, "response"); + assert.deepEqual(subscribed.payload.events, [payload]); + assert.equal(subscribed.payload.latest_seq, 42); +}); + +test("process_state injects the client-tracked agent_online flag", () => { + const frame = roundtrip( + serverFrame({ + process_state: { + revision: 7, + processes: [ + { + id: "proc-1", + label: "dev server", + pid: 4321, + started_at: 1700000000000, + running: true, + }, + ], + }, + }), + ); + const online = decodeServerFrame(frame, { agentOnline: true }); + assert.equal(online.type, "process.state"); + assert.equal(online.payload.agent_online, true); + assert.equal(online.payload.revision, 7); + assert.equal(online.payload.processes[0].started_at, 1700000000000); + // 未置位的 optional finished_at / exit_code 不出现(v1 同语义)。 + assert.equal("finished_at" in online.payload.processes[0], false); + assert.equal("exit_code" in online.payload.processes[0], false); + + const offline = decodeServerFrame(frame, { agentOnline: false }); + assert.equal(offline.payload.agent_online, false); +}); + +test("encodeRequestFrame maps v1 request types onto GatewayEnvelope arms", () => { + const listFrame = decodeClientFrame( + encodeRequestFrame("req-1", "history.list", { page: 2, page_size: 50, cwd: "/tmp/p" }), + ); + assert.equal(listFrame.requestId, "req-1"); + assert.equal(listFrame.payload.case, "agentRequest"); + assert.equal(listFrame.payload.value.payload.case, "historyList"); + assert.deepEqual( + { + page: listFrame.payload.value.payload.value.page, + pageSize: listFrame.payload.value.payload.value.pageSize, + cwd: listFrame.payload.value.payload.value.cwd, + }, + { page: 2, pageSize: 50, cwd: "/tmp/p" }, + ); + + const terminalFrame = decodeClientFrame( + encodeRequestFrame("req-2", "terminal.create", { + cwd: "/workspace", + project_path_key: "/workspace", + cols: 120, + rows: 40, + }), + ); + assert.equal(terminalFrame.payload.value.payload.case, "terminalRequest"); + assert.equal(terminalFrame.payload.value.payload.value.action, "create"); + assert.equal(terminalFrame.payload.value.payload.value.cols, 120); + + // chat.command 的 64 位字段在出站边界收窄为 bigint。 + const commandFrame = decodeClientFrame( + encodeRequestFrame("req-3", "chat.command", { + type: "chat.submit", + payload: { + message: "hi", + conversation_id: "conversation-1", + client_request_id: "client-1", + uploaded_files: [ + { + relative_path: "a.png", + absolute_path: "/tmp/a.png", + file_name: "a.png", + kind: "image", + size_bytes: 4102444800000, + }, + ], + queue_policy: "auto", + }, + }), + ); + assert.equal(commandFrame.payload.case, "chatCommand"); + assert.equal(commandFrame.payload.value.request.uploadedFiles[0].sizeBytes, 4102444800000n); + + assert.throws( + () => encodeRequestFrame("req-4", "not.a.request", {}), + /unsupported gateway request type/, + ); +}); From da4f07ebc92485598dafee5529b66b860494287f Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:20:35 +0800 Subject: [PATCH 07/10] feat(desktop): connect via v2 WebSocket with gRPC fallback and protocol badge - ws_transport: URL derivation (UI port box applied), subprotocol handshake, handshake-vs-auth error classification; serve loop keeps dual-lane merge, reconciliation and backoff; liveness watchdog replaces h2 keepalive - fallback to deprecated v1 gRPC only on handshake-level failure; fallback sessions redial v2 after 10min so transient blips never pin v1 - terminal stream rides /ws/v2/terminal; tokio-tungstenite gains rustls TLS - status snapshot carries protocol; settings page shows Connected (v2)/(v1) --- Cargo.lock | 17 +- crates/agent-gui/src-tauri/Cargo.toml | 2 +- .../src/services/gateway/connection.rs | 212 +++++++ .../src/services/gateway/controller.rs | 2 + .../src/services/gateway/terminal.rs | 164 ++++- .../src-tauri/src/services/gateway/tests.rs | 8 + .../src-tauri/src/services/gateway/types.rs | 2 + .../src/services/gateway/ws_transport.rs | 565 ++++++++++++++++++ crates/agent-gui/src/i18n/config.ts | 2 + .../src/pages/settings/RemoteSection.tsx | 7 +- 10 files changed, 977 insertions(+), 4 deletions(-) create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/ws_transport.rs diff --git a/Cargo.lock b/Cargo.lock index 5a7d833b5..28ed9cc23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6727,8 +6727,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", + "rustls", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite 0.29.0", + "webpki-roots 0.26.11", ] [[package]] @@ -6893,7 +6897,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", - "webpki-roots", + "webpki-roots 1.0.8", ] [[package]] @@ -7079,6 +7083,8 @@ dependencies = [ "httparse", "log", "rand 0.9.4", + "rustls", + "rustls-pki-types", "sha1 0.10.6", "thiserror 2.0.18", ] @@ -7557,6 +7563,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.8", +] + [[package]] name = "webpki-roots" version = "1.0.8" diff --git a/crates/agent-gui/src-tauri/Cargo.toml b/crates/agent-gui/src-tauri/Cargo.toml index 79e73aedd..334ce8965 100644 --- a/crates/agent-gui/src-tauri/Cargo.toml +++ b/crates/agent-gui/src-tauri/Cargo.toml @@ -30,7 +30,7 @@ percent-encoding = "2.3.2" axum = "0.8.9" tokio = { version = "1.52.3", features = ["macros", "net", "sync", "time", "io-util"] } tokio-stream = "0.1.18" -tokio-tungstenite = "0.29.0" +tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3.32" uuid = { version = "1.23.4", features = ["v4"] } base64 = "0.22.1" diff --git a/crates/agent-gui/src-tauri/src/services/gateway/connection.rs b/crates/agent-gui/src-tauri/src/services/gateway/connection.rs index 9a2500250..fc48a2a83 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/connection.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/connection.rs @@ -2,12 +2,14 @@ use std::future::Future; use std::sync::{Arc, Once}; use std::time::{Duration, Instant}; +use futures_util::SinkExt as _; use reqwest::Url; use serde_json::Value; use tauri::Emitter; use tokio::sync::{mpsc, watch}; use tokio_stream::wrappers::ReceiverStream; use tokio_stream::StreamExt as _; +use tokio_tungstenite::tungstenite::Message as WsMessage; use tonic::metadata::MetadataValue; use tonic::transport::{ClientTlsConfig, Endpoint}; @@ -15,8 +17,17 @@ use crate::commands::settings::RemoteSettingsPayload; use crate::runtime::terminal::TerminalEventPayload; use crate::services::gateway_bridge; +use super::gateway_proto::v2; use super::*; +/// v2 链路一次连接尝试的失败分类(模块内部)。 +enum WsServeError { + /// 握手层失败(旧网关无 /ws/v2/agent):允许同一次尝试内回退 gRPC。 + Handshake(String), + /// 鉴权拒绝或链路已建立后出错:不回退,按普通连接错误上抛;下次重连仍先试 v2。 + Fatal(String), +} + struct AbortTaskOnDrop(tauri::async_runtime::JoinHandle<()>); impl Drop for AbortTaskOnDrop { @@ -94,10 +105,206 @@ impl GatewayController { } } + /// 连接尝试入口:先试 v2;握手层失败(旧网关无 /ws/v2/agent,典型 404/非 101)时同一次尝试内 + /// 回退 v1 gRPC,鉴权被拒不回退直接上抛;每次重连都重新从 v2 试起。 + /// 回退会话最多持续 [`GATEWAY_WS_UPGRADE_RETRY_INTERVAL`] 便主动断开重试 v2——防瞬时抖动 + /// 导致长驻 v1、污染"v1 流量归零"信号(重连亚秒级且会话层幂等对账,中断代价可忽略)。 pub(crate) async fn connect_and_serve( self: &Arc, config: RemoteSettingsPayload, config_rx: &mut watch::Receiver, + ) -> Result<(), String> { + match self.connect_and_serve_ws(config.clone(), config_rx).await { + Ok(()) => Ok(()), + Err(WsServeError::Fatal(error)) => Err(error), + Err(WsServeError::Handshake(error)) => { + eprintln!("gateway v2 handshake failed: {error}; falling back to v1 gRPC"); + #[allow(deprecated)] + { + tokio::select! { + result = self.connect_and_serve_grpc(config, config_rx) => result, + _ = tokio::time::sleep(GATEWAY_WS_UPGRADE_RETRY_INTERVAL) => { + eprintln!( + "gateway v1 fallback session reached upgrade-retry window; \ + reconnecting to retry v2" + ); + Ok(()) + } + } + } + } + } + } + + /// v2 主链路:hello 握手(等价 Authenticate)后按 gRPC AgentConnect 完全相同的语义收发信封 + /// (双通道合并、状态迁移、对账、分发),外加取代 h2 keepalive 的存活看门狗。 + async fn connect_and_serve_ws( + self: &Arc, + config: RemoteSettingsPayload, + config_rx: &mut watch::Receiver, + ) -> Result<(), WsServeError> { + let ws_url = build_ws_url(&config.gateway_url, config.grpc_port, GATEWAY_WS_AGENT_PATH) + .map_err(WsServeError::Handshake)?; + let hello = build_client_hello( + &config.token, + effective_agent_id(&config), + crate::app_version().to_string(), + ); + + let connect_result = + await_abortable_on_reconfigure(&config, config_rx, async move { + Ok(connect_agent_ws(&ws_url, hello).await) + }) + .await + .map_err(WsServeError::Fatal)?; + let (mut ws, server_hello) = match connect_result { + None => return Ok(()), + Some(Ok(established)) => established, + Some(Err(WsHandshakeError::Handshake(error))) => { + return Err(WsServeError::Handshake(error)) + } + Some(Err(WsHandshakeError::AuthRejected(error))) => { + return Err(WsServeError::Fatal(error)) + } + }; + + let (outbound_tx, outbound_rx) = mpsc::channel::(4096); + self.set_outbound_sender(Some(outbound_tx)); + // 控制小通道:Pong 不排在数据信封之后(与 gRPC 路径同策略,下方公平合并进出站流)。 + let (outbound_control_tx, outbound_control_rx) = + mpsc::channel::(GATEWAY_OUTBOUND_CONTROL_QUEUE_DEPTH); + self.set_outbound_control_sender(Some(outbound_control_tx)); + let (terminal_stop_tx, terminal_stop_rx) = watch::channel(false); + let terminal_task = self.spawn_terminal_stream_ws(config.clone(), terminal_stop_rx); + + let serve_result = async { + let connected_at = now_unix_seconds(); + self.publish_status(|status| { + status.online = true; + status.enabled = true; + status.configured = true; + status.gateway_url = config.gateway_url.clone(); + status.agent_id = effective_agent_id(&config); + status.session_id = Some(server_hello.session_id.clone()); + status.connected_since = Some(connected_at); + status.last_heartbeat = Some(connected_at); + status.last_error = None; + status.protocol = Some("v2".to_string()); + }); + + let _reconcile_task = AbortTaskOnDrop(self.spawn_post_connect_reconciliation()); + + let mut outbound = ReceiverStream::new(outbound_control_rx) + .merge(ReceiverStream::new(outbound_rx)); + + // 存活看门狗(取代 h2 keepalive):任何入站帧刷新计时;静默超 3×心跳周期发 WS Ping + // 探活,宽限期内仍无入站则判链路已死走重连。服务端 Ping 的 Pong 由 tungstenite 自动回。 + let heartbeat_period = if server_hello.heartbeat_period_seconds > 0 { + Duration::from_secs(u64::from(server_hello.heartbeat_period_seconds)) + } else { + GATEWAY_WS_DEFAULT_HEARTBEAT_PERIOD + }; + let idle_timeout = heartbeat_period * 3; + let mut last_inbound = Instant::now(); + let mut probe_deadline: Option = None; + + let receive_result = loop { + let watchdog_deadline = probe_deadline.unwrap_or(last_inbound + idle_timeout); + tokio::select! { + changed = config_rx.changed() => { + if changed.is_err() { + break Ok(()); + } + let next = config_rx.borrow().clone(); + if next != config { + break Ok(()); + } + } + envelope = outbound.next() => { + match envelope { + None => break Err("gateway outbound channels closed".to_string()), + Some(envelope) => { + let frame = v2::AgentClientFrame { + payload: Some(v2::agent_client_frame::Payload::Envelope(envelope)), + }; + if let Err(error) = ws.send(encode_ws_frame(&frame)).await { + break Err(format!("gateway ws send failed: {error}")); + } + } + } + } + message = ws.next() => { + match message { + None => break Err("gateway ws stream closed".to_string()), + Some(Err(error)) => break Err(format!("gateway ws receive failed: {error}")), + Some(Ok(message)) => { + last_inbound = Instant::now(); + probe_deadline = None; + match message { + WsMessage::Binary(data) => { + let frame: v2::AgentServerFrame = match decode_ws_frame(&data) { + Ok(frame) => frame, + Err(error) => break Err(error), + }; + // 重复 hello 或空帧:忽略(服务端同样宽容)。 + if let Some(v2::agent_server_frame::Payload::Envelope(envelope)) = frame.payload { + self.touch_heartbeat(); + if let Err(error) = self.handle_gateway_envelope(envelope).await { + break Err(error); + } + } + } + WsMessage::Close(frame) => { + break Err(match frame { + Some(frame) => format!( + "gateway ws closed (code {}): {}", + u16::from(frame.code), + frame.reason + ), + None => "gateway ws closed".to_string(), + }); + } + // v2 链路不允许文本帧,视为协议错误。 + WsMessage::Text(_) => { + break Err("gateway ws sent unexpected text frame".to_string()); + } + // Ping/Pong 由 tungstenite 处理,此处仅刷新看门狗。 + _ => {} + } + } + } + } + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(watchdog_deadline)) => { + if probe_deadline.is_some() { + break Err(format!( + "gateway ws link stale: no inbound frames for {}s", + idle_timeout.saturating_add(GATEWAY_WS_PROBE_GRACE).as_secs() + )); + } + if let Err(error) = ws.send(WsMessage::Ping(Vec::new().into())).await { + break Err(format!("gateway ws liveness ping failed: {error}")); + } + probe_deadline = Some(Instant::now() + GATEWAY_WS_PROBE_GRACE); + } + } + }; + receive_result + } + .await; + + let _ = terminal_stop_tx.send(true); + terminal_task.abort(); + self.set_terminal_stream_sender(None); + serve_result.map_err(WsServeError::Fatal) + } + + /// v1 gRPC 主链路(Authenticate + AgentConnect),行为与迁移前逐字一致。 + #[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] + #[allow(deprecated)] // 函数体即回退路径,成串调用同批弃用的 gRPC 辅助函数。 + pub(crate) async fn connect_and_serve_grpc( + self: &Arc, + config: RemoteSettingsPayload, + config_rx: &mut watch::Receiver, ) -> Result<(), String> { let grpc_url = build_grpc_url(&config)?; // The heartbeat interval setting drives the h2 keepalive cadence; the @@ -182,6 +389,7 @@ impl GatewayController { status.connected_since = Some(connected_at); status.last_heartbeat = Some(connected_at); status.last_error = None; + status.protocol = Some("v1".to_string()); }); let _reconcile_task = AbortTaskOnDrop(self.spawn_post_connect_reconciliation()); @@ -492,6 +700,7 @@ pub(crate) fn build_error_response_envelope( } } +#[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] pub(crate) fn build_grpc_url(config: &RemoteSettingsPayload) -> Result { let grpc_endpoint = config.grpc_endpoint.trim(); if !grpc_endpoint.is_empty() { @@ -534,6 +743,7 @@ pub(crate) fn is_h2_protocol_error(message: &str) -> bool { normalized.contains("h2 protocol error") || normalized.contains("http2 error") } +#[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] pub(crate) fn build_endpoint( grpc_url: &str, keepalive_interval: Duration, @@ -573,6 +783,7 @@ pub(crate) fn ensure_rustls_crypto_provider() { }); } +#[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] pub(crate) fn insert_bearer_metadata( metadata: &mut tonic::metadata::MetadataMap, token: &str, @@ -616,4 +827,5 @@ pub(crate) fn set_disconnected_status( status.connected_since = None; status.last_heartbeat = None; status.last_error = last_error; + status.protocol = None; } diff --git a/crates/agent-gui/src-tauri/src/services/gateway/controller.rs b/crates/agent-gui/src-tauri/src/services/gateway/controller.rs index e56462bf5..22bd7216e 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/controller.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/controller.rs @@ -52,6 +52,7 @@ impl GatewayController { connected_since: None, last_heartbeat: None, last_error: None, + protocol: None, }), outbound_tx: Mutex::new(None), outbound_control_tx: Mutex::new(None), @@ -338,6 +339,7 @@ impl GatewayController { connected_since: None, last_heartbeat: None, last_error: Some("gateway status lock poisoned".to_string()), + protocol: None, }) } diff --git a/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs b/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs index 415982716..f5639f37c 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs @@ -1,8 +1,11 @@ use std::sync::Arc; use std::time::Instant; +use futures_util::SinkExt as _; use tokio::sync::{mpsc, watch}; use tokio_stream::wrappers::ReceiverStream; +use tokio_stream::StreamExt as _; +use tokio_tungstenite::tungstenite::Message as WsMessage; use uuid::Uuid; use crate::commands::settings::RemoteSettingsPayload; @@ -15,9 +18,160 @@ use crate::runtime::terminal::{ TerminalSshCreateResponse, TerminalStreamEventPayload, TerminalStreamSnapshotResponse, }; +use super::gateway_proto::v2; use super::*; impl GatewayController { + /// v2 终端数据面(/ws/v2/terminal,角色 AGENT):PTY/注册表侧播种口与 gRPC 版完全一致; + /// 仅当主链路运行在 v2 上时由 connect_and_serve_ws 生成本任务,传输选择随主链路贯通。 + pub(crate) fn spawn_terminal_stream_ws( + self: &Arc, + config: RemoteSettingsPayload, + stop_rx: watch::Receiver, + ) -> tauri::async_runtime::JoinHandle<()> { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + controller.run_terminal_stream_ws(config, stop_rx).await; + }) + } + + /// v2 终端流重连主循环,骨架与 gRPC 版一致(同一组退避常量)。 + pub(crate) async fn run_terminal_stream_ws( + self: Arc, + config: RemoteSettingsPayload, + mut stop_rx: watch::Receiver, + ) { + let mut reconnect_delay = GATEWAY_TERMINAL_STREAM_RECONNECT_MIN; + + loop { + if *stop_rx.borrow() { + break; + } + + let attempt_started = Instant::now(); + let result = Arc::clone(&self) + .run_terminal_stream_ws_once(config.clone(), stop_rx.clone()) + .await; + if *stop_rx.borrow() { + break; + } + self.set_terminal_stream_sender(None); + + if attempt_started.elapsed() >= GATEWAY_TERMINAL_STREAM_STABLE_AFTER { + reconnect_delay = GATEWAY_TERMINAL_STREAM_RECONNECT_MIN; + } + match result { + Ok(()) => eprintln!("gateway terminal ws stream closed; reconnecting"), + Err(error) => { + eprintln!("gateway terminal ws stream stopped: {error}; reconnecting") + } + } + + let delay = reconnect_delay; + reconnect_delay = + std::cmp::min(reconnect_delay * 2, GATEWAY_TERMINAL_STREAM_RECONNECT_MAX); + tokio::select! { + changed = stop_rx.changed() => { + if changed.is_err() || *stop_rx.borrow() { + break; + } + } + _ = tokio::time::sleep(delay) => {} + } + } + + self.set_terminal_stream_sender(None); + } + + /// 单次 v2 终端流连接:hello ok=true 即就绪(v1 的合成 "gateway-ready" detach 握手帧不复存在), + /// 随后双向透传 TerminalStreamFrame;周期 WS Ping 取代 v1 的 detach 保活帧。 + pub(crate) async fn run_terminal_stream_ws_once( + self: Arc, + config: RemoteSettingsPayload, + mut stop_rx: watch::Receiver, + ) -> Result<(), String> { + let ws_url = build_ws_url(&config.gateway_url, config.grpc_port, GATEWAY_WS_TERMINAL_PATH)?; + let hello = build_client_hello( + &config.token, + effective_agent_id(&config), + crate::app_version().to_string(), + ); + + let connect = connect_terminal_ws(&ws_url, hello); + tokio::pin!(connect); + let (mut ws, _server_hello) = tokio::select! { + changed = stop_rx.changed() => { + let _ = changed; + return Ok(()); + } + result = &mut connect => { + result.map_err(|error| format!("gateway terminal ws connect failed: {error}"))? + } + }; + + let (terminal_tx, mut terminal_rx) = mpsc::channel::(4096); + self.set_terminal_stream_sender(Some(terminal_tx.clone())); + + let result = async { + let mut keepalive = tokio::time::interval(GATEWAY_TERMINAL_STREAM_KEEPALIVE_INTERVAL); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + keepalive.tick().await; + loop { + tokio::select! { + changed = stop_rx.changed() => { + if changed.is_err() || *stop_rx.borrow() { + return Ok(()); + } + } + _ = keepalive.tick() => { + ws.send(WsMessage::Ping(Vec::new().into())) + .await + .map_err(|error| format!("gateway terminal ws keepalive failed: {error}"))?; + } + frame = terminal_rx.recv() => { + let Some(frame) = frame else { + return Err("gateway terminal ws outbound channel closed".to_string()); + }; + let client_frame = v2::TerminalClientFrame { + payload: Some(v2::terminal_client_frame::Payload::Frame(frame)), + }; + ws.send(encode_ws_frame(&client_frame)) + .await + .map_err(|error| format!("gateway terminal ws send failed: {error}"))?; + } + message = ws.next() => { + match message { + None => return Ok(()), + Some(Err(error)) => { + return Err(format!("gateway terminal ws receive failed: {error}")); + } + Some(Ok(WsMessage::Binary(data))) => { + let server_frame: v2::TerminalServerFrame = decode_ws_frame(&data)?; + if let Some(v2::terminal_server_frame::Payload::Frame(frame)) = + server_frame.payload + { + if let Err(error) = self.handle_terminal_stream_frame(frame).await { + eprintln!("handle gateway terminal stream frame failed: {error}"); + } + } + } + Some(Ok(WsMessage::Close(_))) => return Ok(()), + // Ping/Pong 由 tungstenite 自动处理。 + Some(Ok(_)) => {} + } + } + } + } + } + .await; + + self.clear_terminal_stream_sender_if_current(&terminal_tx); + result + } + + /// v1 gRPC 终端流入口,行为与迁移前逐字一致。 + #[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] + #[allow(deprecated)] // 函数体即回退路径,成串调用同批弃用的 gRPC 辅助函数。 pub(crate) fn spawn_terminal_stream( self: &Arc, client: proto::agent_gateway_client::AgentGatewayClient, @@ -32,6 +186,8 @@ impl GatewayController { }) } + #[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] + #[allow(deprecated)] // 函数体即回退路径,成串调用同批弃用的 gRPC 辅助函数。 pub(crate) async fn run_terminal_stream( self: Arc, client: proto::agent_gateway_client::AgentGatewayClient, @@ -78,6 +234,8 @@ impl GatewayController { self.set_terminal_stream_sender(None); } + #[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] + #[allow(deprecated)] // 函数体即回退路径,成串调用同批弃用的 gRPC 辅助函数。 pub(crate) async fn run_terminal_stream_once( self: Arc, mut client: proto::agent_gateway_client::AgentGatewayClient, @@ -842,7 +1000,11 @@ pub(crate) fn format_gateway_terminal_stream_rpc_error( return format!("gateway terminal stream {phase} failed: {message}"); } - let endpoint = build_grpc_url(config).unwrap_or_else(|_| "invalid endpoint".to_string()); + let endpoint = { + // gRPC 回退路径专用的错误提示。 + #[allow(deprecated)] + build_grpc_url(config).unwrap_or_else(|_| "invalid endpoint".to_string()) + }; format!( "gateway terminal stream {phase} failed: {message}. \ The gateway terminal stream requires a gRPC endpoint that supports HTTP/2 bidi streams; \ diff --git a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs index 7d39f874d..f41c7257e 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs @@ -1,3 +1,5 @@ +// build_endpoint / build_grpc_url 属 gRPC 回退路径的既有覆盖:v1 移除时一并删除。 +#[allow(deprecated)] use super::{ build_chat_event_envelope, build_chat_runtime_snapshot_envelope, build_endpoint, build_gateway_runtime_status_envelope, build_grpc_url, @@ -499,6 +501,7 @@ fn set_disconnected_status_resets_runtime_fields_for_new_config() { connected_since: Some(123), last_heartbeat: Some(456), last_error: Some("previous error".to_string()), + protocol: Some("v2".to_string()), }; set_disconnected_status( @@ -560,6 +563,7 @@ fn gateway_connection_nudge_detects_offline_and_stale_sessions() { connected_since: None, last_heartbeat: None, last_error: None, + protocol: None, }; assert!(gateway_connection_needs_restart(&status, &config, 1_000)); @@ -593,12 +597,16 @@ fn gateway_reconnect_backoff_is_fast_after_a_stable_session_and_bounded_on_failu } #[test] +// gRPC 回退路径的既有覆盖:v1 移除时与被测函数一并删除。 +#[allow(deprecated)] fn build_https_gateway_endpoint_initializes_tls_provider() { build_endpoint("https://agent.cnweb.org:443", Duration::from_secs(30)) .expect("build https gateway endpoint"); } #[test] +// gRPC 回退路径的既有覆盖:v1 移除时与被测函数一并删除。 +#[allow(deprecated)] fn build_grpc_url_prefers_explicit_endpoint() { let config = RemoteSettingsPayload { enabled: true, diff --git a/crates/agent-gui/src-tauri/src/services/gateway/types.rs b/crates/agent-gui/src-tauri/src/services/gateway/types.rs index 59e03400c..c50f1f277 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/types.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/types.rs @@ -14,6 +14,8 @@ pub struct GatewayStatusSnapshot { pub connected_since: Option, pub last_heartbeat: Option, pub last_error: Option, + /// 协议链路:"v2"(WebSocket+Protobuf)或 "v1"(弃用的 gRPC 回退);未连接时 None。 + pub protocol: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/crates/agent-gui/src-tauri/src/services/gateway/ws_transport.rs b/crates/agent-gui/src-tauri/src/services/gateway/ws_transport.rs new file mode 100644 index 000000000..664c9428e --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/ws_transport.rs @@ -0,0 +1,565 @@ +//! v2 线协议(WebSocket+Protobuf)客户端传输层:URL 推导、子协议建连、hello 握手、prost 帧编解码, +//! 并把握手失败分为「握手层失败(可回退 v1 gRPC)」与「鉴权拒绝(不得回退)」。刻意不依赖 tauri, +//! 便于纯 tokio 测试;业务信封收发主循环由 connection.rs / terminal.rs 驱动。 + +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use prost::Message as ProstMessage; +use reqwest::Url; +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::header::SEC_WEBSOCKET_PROTOCOL; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::protocol::CloseFrame; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; + +use super::ensure_rustls_crypto_provider; +use super::gateway_proto::v2; + +/// v2 WebSocket 子协议名(与 Go 侧 `pbws.Subprotocol` 一致;服务端必须回显)。 +pub(crate) const GATEWAY_WS_SUBPROTOCOL: &str = "liveagent.v2.pb"; +/// v2 协议版本号(`ClientHello.protocol_version`)。 +pub(crate) const GATEWAY_WS_PROTOCOL_VERSION: u32 = 2; +/// 桌面端主链路路径(替代 v1 gRPC Authenticate + AgentConnect)。 +pub(crate) const GATEWAY_WS_AGENT_PATH: &str = "/ws/v2/agent"; +/// 终端数据面路径(替代 v1 gRPC AgentTerminalConnect)。 +pub(crate) const GATEWAY_WS_TERMINAL_PATH: &str = "/ws/v2/terminal"; +/// 鉴权失败时服务端的自定义关闭码(Go 侧 `closeCodeUnauthorized`)。 +pub(crate) const GATEWAY_WS_CLOSE_CODE_UNAUTHORIZED: u16 = 4401; +/// 建连 + hello 应答的整体超时(对齐 v1 authenticate 的 10 秒)。 +pub(crate) const GATEWAY_WS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); + +pub(crate) type WsStream = WebSocketStream>; + +/// v2 握手阶段的错误分类:决定同一次连接尝试内是否允许回退 v1 gRPC。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WsHandshakeError { + /// 握手层失败(TCP/TLS/404/非 101/子协议不符/hello 超时等):视为旧网关无 /ws/v2,可回退 gRPC。 + Handshake(String), + /// 鉴权被拒(ServerHello ok=false 或 4401 关闭码):不得回退,与 v1 Authenticate 失败同等对待。 + AuthRejected(String), +} + +impl std::fmt::Display for WsHandshakeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WsHandshakeError::Handshake(message) => write!(f, "{message}"), + WsHandshakeError::AuthRejected(message) => write!(f, "{message}"), + } + } +} + +/// 从网关基址推导 v2 WS URL:http→ws、https→wss,保留路径前缀(反代子路径),丢弃查询串与片段。 +/// 端口取设置里的 `grpc_port`(v1 命名遗留,实义网关端口)无条件覆盖基址端口,与界面预览拼法 +/// 一致;v2 与 gRPC 回退因此指向同一端口。 +pub(crate) fn build_ws_url( + gateway_url: &str, + gateway_port: u16, + path: &str, +) -> Result { + let trimmed = gateway_url.trim(); + if trimmed.is_empty() { + return Err("gateway URL is empty".to_string()); + } + let mut url = Url::parse(trimmed).map_err(|e| format!("invalid gateway URL: {e}"))?; + let ws_scheme = match url.scheme() { + "http" | "ws" => "ws", + "https" | "wss" => "wss", + _ => return Err("gateway URL must start with http:// or https://".to_string()), + }; + url.set_scheme(ws_scheme) + .map_err(|_| "failed to apply websocket scheme to gateway URL".to_string())?; + if gateway_port != 0 { + url.set_port(Some(gateway_port)) + .map_err(|_| "failed to apply gateway port to websocket URL".to_string())?; + } + url.set_query(None); + url.set_fragment(None); + let base_path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{base_path}{path}")); + Ok(url.to_string()) +} + +/// 构造 v2 hello 载荷:桌面端两条链路均以 CLIENT_ROLE_AGENT 建连,字段对应 v1 AuthRequest。 +pub(crate) fn build_client_hello( + token: &str, + agent_id: String, + agent_version: String, +) -> v2::ClientHello { + v2::ClientHello { + protocol_version: GATEWAY_WS_PROTOCOL_VERSION, + role: v2::ClientRole::Agent as i32, + token: token.trim().to_string(), + agent_id, + agent_version: agent_version.clone(), + client_name: "desktop".to_string(), + client_version: agent_version, + } +} + +/// prost 编码为一条 WS 二进制消息(v2 约定:一帧一消息,无长度前缀)。 +pub(crate) fn encode_ws_frame(frame: &M) -> Message { + Message::Binary(frame.encode_to_vec().into()) +} + +/// 从 WS 二进制消息解码一条 prost 帧。 +pub(crate) fn decode_ws_frame(data: &[u8]) -> Result { + M::decode(data).map_err(|error| format!("decode gateway v2 frame failed: {error}")) +} + +/// ServerHello 校验:ok=false 一律按鉴权拒绝处理(服务端随即以 4401 关闭)。 +pub(crate) fn vet_server_hello( + hello: v2::ServerHello, +) -> Result { + if hello.ok { + return Ok(hello); + } + let message = hello.message.trim(); + Err(WsHandshakeError::AuthRejected(if message.is_empty() { + "gateway authentication failed".to_string() + } else { + message.to_string() + })) +} + +/// hello 应答前收到关闭帧的分类:4401 为鉴权拒绝,其余按握手层失败处理。 +pub(crate) fn classify_pre_hello_close(frame: Option<&CloseFrame>) -> WsHandshakeError { + match frame { + Some(frame) if u16::from(frame.code) == GATEWAY_WS_CLOSE_CODE_UNAUTHORIZED => { + let reason = frame.reason.trim(); + WsHandshakeError::AuthRejected(if reason.is_empty() { + "gateway authentication failed".to_string() + } else { + reason.to_string() + }) + } + Some(frame) => WsHandshakeError::Handshake(format!( + "gateway v2 connection closed before hello (code {})", + u16::from(frame.code) + )), + None => { + WsHandshakeError::Handshake("gateway v2 connection closed before hello".to_string()) + } + } +} + +/// 建立 v2 主链路(/ws/v2/agent):建连并完成 hello 握手,返回流与 ServerHello。 +pub(crate) async fn connect_agent_ws( + url: &str, + hello: v2::ClientHello, +) -> Result<(WsStream, v2::ServerHello), WsHandshakeError> { + let frame = encode_ws_frame(&v2::AgentClientFrame { + payload: Some(v2::agent_client_frame::Payload::Hello(hello)), + }); + connect_and_hello(url, frame, decode_agent_server_hello).await +} + +/// 建立 v2 终端数据面链路:连接 /ws/v2/terminal(角色 AGENT),流程同主链路。 +pub(crate) async fn connect_terminal_ws( + url: &str, + hello: v2::ClientHello, +) -> Result<(WsStream, v2::ServerHello), WsHandshakeError> { + let frame = encode_ws_frame(&v2::TerminalClientFrame { + payload: Some(v2::terminal_client_frame::Payload::Hello(hello)), + }); + connect_and_hello(url, frame, decode_terminal_server_hello).await +} + +fn decode_agent_server_hello(data: &[u8]) -> Result, String> { + let frame: v2::AgentServerFrame = decode_ws_frame(data)?; + Ok(match frame.payload { + Some(v2::agent_server_frame::Payload::Hello(hello)) => Some(hello), + _ => None, + }) +} + +fn decode_terminal_server_hello(data: &[u8]) -> Result, String> { + let frame: v2::TerminalServerFrame = decode_ws_frame(data)?; + Ok(match frame.payload { + Some(v2::terminal_server_frame::Payload::Hello(hello)) => Some(hello), + _ => None, + }) +} + +/// 建连 + hello 握手的公共骨架,整体受 [`GATEWAY_WS_HANDSHAKE_TIMEOUT`] 约束。 +async fn connect_and_hello( + url: &str, + hello_frame: Message, + decode_hello: fn(&[u8]) -> Result, String>, +) -> Result<(WsStream, v2::ServerHello), WsHandshakeError> { + let handshake = async { + let mut stream = connect_ws(url).await?; + stream.send(hello_frame).await.map_err(|error| { + WsHandshakeError::Handshake(format!("send gateway v2 hello failed: {error}")) + })?; + let hello = await_server_hello(&mut stream, decode_hello).await?; + Ok((stream, hello)) + }; + tokio::time::timeout(GATEWAY_WS_HANDSHAKE_TIMEOUT, handshake) + .await + .map_err(|_| WsHandshakeError::Handshake("gateway v2 handshake timed out".to_string()))? +} + +/// 以 v2 子协议发起 WS 升级并校验服务端回显(旧网关兜底路由可能接受升级却不认识 v2 帧)。 +async fn connect_ws(url: &str) -> Result { + if url.starts_with("wss://") { + // rustls 连接器复用进程级默认 crypto provider,与 tonic TLS 共用同一次 ring 安装。 + ensure_rustls_crypto_provider(); + } + let mut request = url.into_client_request().map_err(|error| { + WsHandshakeError::Handshake(format!("build gateway v2 request failed: {error}")) + })?; + request.headers_mut().insert( + SEC_WEBSOCKET_PROTOCOL, + HeaderValue::from_static(GATEWAY_WS_SUBPROTOCOL), + ); + let (stream, response) = connect_async(request).await.map_err(|error| { + WsHandshakeError::Handshake(format!("gateway v2 connect failed: {error}")) + })?; + let echoed = response + .headers() + .get(SEC_WEBSOCKET_PROTOCOL) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if echoed != GATEWAY_WS_SUBPROTOCOL { + return Err(WsHandshakeError::Handshake(format!( + "gateway v2 subprotocol mismatch: expected {GATEWAY_WS_SUBPROTOCOL:?}, got {echoed:?}" + ))); + } + Ok(stream) +} + +/// 等待 ServerHello;hello 前容忍 Ping/Pong 控制帧,其余帧按协议错误(hello 必为首帧)。 +async fn await_server_hello( + stream: &mut WsStream, + decode_hello: fn(&[u8]) -> Result, String>, +) -> Result { + loop { + match stream.next().await { + None => return Err(classify_pre_hello_close(None)), + Some(Err(error)) => { + return Err(WsHandshakeError::Handshake(format!( + "gateway v2 hello receive failed: {error}" + ))) + } + Some(Ok(Message::Binary(data))) => { + return match decode_hello(&data).map_err(WsHandshakeError::Handshake)? { + Some(hello) => vet_server_hello(hello), + None => Err(WsHandshakeError::Handshake( + "gateway v2 sent a non-hello first frame".to_string(), + )), + }; + } + Some(Ok(Message::Close(frame))) => return Err(classify_pre_hello_close(frame.as_ref())), + // WS 控制帧(Ping/Pong)不参与握手语义。 + Some(Ok(_)) => continue, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::gateway::proto; + use tokio_tungstenite::tungstenite::handshake::server::{ + Request as ServerRequest, Response as ServerResponse, + }; + use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + + #[test] + fn build_ws_url_maps_http_to_ws() { + // 端口框(gateway_port)与界面预览一致:无条件覆盖基址端口。 + assert_eq!( + build_ws_url("http://gateway.example.com:8080", 8080, GATEWAY_WS_AGENT_PATH) + .expect("build ws url"), + "ws://gateway.example.com:8080/ws/v2/agent" + ); + } + + #[test] + fn build_ws_url_applies_configured_port_to_portless_base() { + // 回归用例:端口单独存于 grpc_port、基址不带端口时,曾因未补端口导致 v2 拨到 80。 + assert_eq!( + build_ws_url("http://127.0.0.1", 50052, GATEWAY_WS_AGENT_PATH) + .expect("build ws url with configured port"), + "ws://127.0.0.1:50052/ws/v2/agent" + ); + } + + #[test] + fn build_ws_url_overrides_explicit_base_port() { + // 界面预览的语义:端口框优先于基址里写的端口。 + assert_eq!( + build_ws_url("http://gateway.example.com:9999", 50052, GATEWAY_WS_AGENT_PATH) + .expect("build ws url overriding base port"), + "ws://gateway.example.com:50052/ws/v2/agent" + ); + } + + #[test] + fn build_ws_url_maps_https_to_wss() { + assert_eq!( + build_ws_url("https://gateway.example.com", 443, GATEWAY_WS_TERMINAL_PATH) + .expect("build wss url"), + "wss://gateway.example.com/ws/v2/terminal" + ); + } + + #[test] + fn build_ws_url_preserves_base_path_and_strips_query() { + assert_eq!( + build_ws_url( + " https://gateway.example.com/liveagent/?token=x#frag ", + 443, + GATEWAY_WS_AGENT_PATH + ) + .expect("build ws url with base path"), + "wss://gateway.example.com/liveagent/ws/v2/agent" + ); + } + + #[test] + fn build_ws_url_rejects_bad_input() { + assert!(build_ws_url("", 50052, GATEWAY_WS_AGENT_PATH).is_err()); + assert!(build_ws_url(" ", 50052, GATEWAY_WS_AGENT_PATH).is_err()); + assert!(build_ws_url("ftp://gateway.example.com", 50052, GATEWAY_WS_AGENT_PATH).is_err()); + assert!(build_ws_url("not a url", 50052, GATEWAY_WS_AGENT_PATH).is_err()); + } + + #[test] + fn vet_server_hello_classifies_auth_rejection() { + let ok = vet_server_hello(v2::ServerHello { + ok: true, + session_id: "session-1".to_string(), + ..Default::default() + }) + .expect("ok hello passes"); + assert_eq!(ok.session_id, "session-1"); + + assert_eq!( + vet_server_hello(v2::ServerHello { + ok: false, + message: " unauthorized ".to_string(), + ..Default::default() + }), + Err(WsHandshakeError::AuthRejected("unauthorized".to_string())) + ); + assert_eq!( + vet_server_hello(v2::ServerHello::default()), + Err(WsHandshakeError::AuthRejected( + "gateway authentication failed".to_string() + )) + ); + } + + #[test] + fn classify_pre_hello_close_distinguishes_unauthorized() { + let unauthorized = CloseFrame { + code: CloseCode::from(GATEWAY_WS_CLOSE_CODE_UNAUTHORIZED), + reason: "unauthorized".into(), + }; + assert_eq!( + classify_pre_hello_close(Some(&unauthorized)), + WsHandshakeError::AuthRejected("unauthorized".to_string()) + ); + + let normal = CloseFrame { + code: CloseCode::Normal, + reason: "".into(), + }; + assert!(matches!( + classify_pre_hello_close(Some(&normal)), + WsHandshakeError::Handshake(_) + )); + assert!(matches!( + classify_pre_hello_close(None), + WsHandshakeError::Handshake(_) + )); + } + + fn echo_subprotocol( + _request: &ServerRequest, + mut response: ServerResponse, + ) -> Result + { + response.headers_mut().insert( + SEC_WEBSOCKET_PROTOCOL, + HeaderValue::from_static(GATEWAY_WS_SUBPROTOCOL), + ); + Ok(response) + } + + async fn read_binary( + ws: &mut WebSocketStream, + ) -> Vec { + loop { + match ws.next().await.expect("ws frame").expect("ws message") { + Message::Binary(data) => return data.to_vec(), + _ => continue, + } + } + } + + #[tokio::test] + async fn agent_handshake_and_envelope_roundtrip() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind local listener"); + let addr = listener.local_addr().expect("local addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept tcp"); + let mut ws = tokio_tungstenite::accept_hdr_async(stream, echo_subprotocol) + .await + .expect("accept ws"); + + // 首帧必须是携带 AGENT 角色与令牌的 hello。 + let hello_frame: v2::AgentClientFrame = + decode_ws_frame(&read_binary(&mut ws).await).expect("decode hello frame"); + let Some(v2::agent_client_frame::Payload::Hello(hello)) = hello_frame.payload else { + panic!("first frame must be hello"); + }; + assert_eq!(hello.protocol_version, GATEWAY_WS_PROTOCOL_VERSION); + assert_eq!(hello.role, v2::ClientRole::Agent as i32); + assert_eq!(hello.token, "test-token"); + assert_eq!(hello.client_name, "desktop"); + + ws.send(encode_ws_frame(&v2::AgentServerFrame { + payload: Some(v2::agent_server_frame::Payload::Hello(v2::ServerHello { + ok: true, + session_id: "session-9".to_string(), + heartbeat_period_seconds: 15, + ..Default::default() + })), + })) + .await + .expect("send server hello"); + + // 回显一条信封(request_id 原样带回)。 + let envelope_frame: v2::AgentClientFrame = + decode_ws_frame(&read_binary(&mut ws).await).expect("decode envelope frame"); + let Some(v2::agent_client_frame::Payload::Envelope(envelope)) = envelope_frame.payload + else { + panic!("second frame must be an envelope"); + }; + ws.send(encode_ws_frame(&v2::AgentServerFrame { + payload: Some(v2::agent_server_frame::Payload::Envelope( + proto::GatewayEnvelope { + request_id: envelope.request_id, + timestamp: 1, + payload: Some(proto::gateway_envelope::Payload::Ping( + proto::PingRequest { timestamp: 1 }, + )), + }, + )), + })) + .await + .expect("send envelope reply"); + }); + + let (mut ws, server_hello) = connect_agent_ws( + &format!("ws://{addr}"), + build_client_hello("test-token", "agent-1".to_string(), "0.0.0".to_string()), + ) + .await + .expect("agent ws handshake"); + assert_eq!(server_hello.session_id, "session-9"); + assert_eq!(server_hello.heartbeat_period_seconds, 15); + + ws.send(encode_ws_frame(&v2::AgentClientFrame { + payload: Some(v2::agent_client_frame::Payload::Envelope( + proto::AgentEnvelope { + request_id: "req-1".to_string(), + timestamp: 1, + payload: Some(proto::agent_envelope::Payload::Pong(proto::PongResponse { + timestamp: 1, + })), + }, + )), + })) + .await + .expect("send envelope"); + + let reply = loop { + match ws.next().await.expect("reply frame").expect("reply message") { + Message::Binary(data) => { + break decode_ws_frame::(&data).expect("decode reply") + } + _ => continue, + } + }; + let Some(v2::agent_server_frame::Payload::Envelope(envelope)) = reply.payload else { + panic!("reply must be an envelope"); + }; + assert_eq!(envelope.request_id, "req-1"); + + server.await.expect("server task"); + } + + #[tokio::test] + async fn agent_handshake_rejection_is_not_a_handshake_failure() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind local listener"); + let addr = listener.local_addr().expect("local addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept tcp"); + let mut ws = tokio_tungstenite::accept_hdr_async(stream, echo_subprotocol) + .await + .expect("accept ws"); + let _ = read_binary(&mut ws).await; + ws.send(encode_ws_frame(&v2::AgentServerFrame { + payload: Some(v2::agent_server_frame::Payload::Hello(v2::ServerHello { + ok: false, + message: "unauthorized".to_string(), + ..Default::default() + })), + })) + .await + .expect("send rejection hello"); + }); + + let result = connect_agent_ws( + &format!("ws://{addr}"), + build_client_hello("bad-token", "agent-1".to_string(), "0.0.0".to_string()), + ) + .await; + assert_eq!( + result.err(), + Some(WsHandshakeError::AuthRejected("unauthorized".to_string())) + ); + + server.await.expect("server task"); + } + + #[tokio::test] + async fn missing_subprotocol_echo_is_a_handshake_failure() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind local listener"); + let addr = listener.local_addr().expect("local addr"); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept tcp"); + // 不回显子协议:模拟旧网关上恰好接受任意升级的兜底路由。 + let _ws = tokio_tungstenite::accept_async(stream).await.expect("accept ws"); + }); + + let result = connect_agent_ws( + &format!("ws://{addr}"), + build_client_hello("test-token", "agent-1".to_string(), "0.0.0".to_string()), + ) + .await; + match result.err() { + Some(WsHandshakeError::Handshake(message)) => { + assert!(message.contains("subprotocol"), "unexpected: {message}"); + } + other => panic!("expected handshake failure, got {other:?}"), + } + + server.await.expect("server task"); + } +} diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 0badafa91..548d45ac6 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1517,6 +1517,7 @@ export const translations: Record> = { "settings.remoteTitle": "远程访问", "settings.remoteDesc": "通过云端 Gateway 远程控制本地 LiveAgent", "settings.remoteConnected": "已连接", + "settings.remoteConnectedProtocol": "已连接 ({protocol})", "settings.remoteDisconnected": "未连接", "settings.remoteEnable": "启用远程访问", "settings.remoteDisable": "关闭远程访问", @@ -3471,6 +3472,7 @@ export const translations: Record> = { "settings.remoteTitle": "Remote Access", "settings.remoteDesc": "Control your local LiveAgent remotely via cloud Gateway", "settings.remoteConnected": "Connected", + "settings.remoteConnectedProtocol": "Connected ({protocol})", "settings.remoteDisconnected": "Disconnected", "settings.remoteEnable": "Enable remote access", "settings.remoteDisable": "Disable remote access", diff --git a/crates/agent-gui/src/pages/settings/RemoteSection.tsx b/crates/agent-gui/src/pages/settings/RemoteSection.tsx index 07dda4888..3597e5877 100644 --- a/crates/agent-gui/src/pages/settings/RemoteSection.tsx +++ b/crates/agent-gui/src/pages/settings/RemoteSection.tsx @@ -137,6 +137,8 @@ type GatewayRuntimeStatus = { connectedSince?: number | null; lastHeartbeat?: number | null; lastError?: string | null; + /** 当前链路协议:"v2"(WebSocket+Protobuf)或 "v1"(弃用的 gRPC 回退)。 */ + protocol?: string | null; }; function updateRemoteSettings( @@ -313,8 +315,11 @@ export function RemoteSection(props: SettingsSectionProps) { const isConnected = Boolean(status.online); const grpcEndpoint = useMemo(() => buildGrpcEndpoint(settings.remote), [settings.remote]); + const connectedProtocol = status.protocol?.trim(); const statusText = isConnected - ? t("settings.remoteConnected") + ? connectedProtocol + ? t("settings.remoteConnectedProtocol").replace("{protocol}", connectedProtocol) + : t("settings.remoteConnected") : settings.remote.enabled ? status.lastError?.trim() || t("settings.remoteDisconnected") : t("settings.remoteDisconnected"); From c712907569892b5968f285649dea912390fce7b0 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:21:19 +0800 Subject: [PATCH 08/10] refactor(gateway): mark v1 protocol surface deprecated with runtime counters - Go 'Deprecated:' markers on the JSON WebSocket server, route table, all domain handlers, payload shaping, terminal stream, and gRPC service - v1 connections/requests increment protocol_usage counters and log WARN, so operators can watch v1 traffic reach zero before removal - command-queue-timeout flag becomes a documented no-op --- crates/agent-gateway/internal/config/config.go | 8 ++++++-- crates/agent-gateway/internal/server/grpc.go | 18 ++++++++++++++++++ .../server/websocket_chat_queue_handlers.go | 1 + .../internal/server/websocket_cron_handlers.go | 1 + .../internal/server/websocket_fs_handlers.go | 1 + .../internal/server/websocket_git_handlers.go | 1 + .../server/websocket_memory_handlers.go | 1 + .../internal/server/websocket_payloads.go | 1 + .../server/websocket_process_handlers.go | 1 + .../server/websocket_provider_handlers.go | 1 + .../internal/server/websocket_routes.go | 6 ++++++ .../server/websocket_settings_handlers.go | 1 + .../internal/server/websocket_sftp_handlers.go | 1 + .../server/websocket_skills_handlers.go | 1 + .../server/websocket_tunnel_handlers.go | 1 + .../server/websocket_upload_handlers.go | 1 + .../server/websocket_workspace_handlers.go | 1 + 17 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/agent-gateway/internal/config/config.go b/crates/agent-gateway/internal/config/config.go index 3a2475215..edefc1d11 100644 --- a/crates/agent-gateway/internal/config/config.go +++ b/crates/agent-gateway/internal/config/config.go @@ -28,7 +28,11 @@ type Config struct { WebSocketWriteQueueSize int GRPCMaxMessageBytes int RelayBufferSeconds int - CommandQueueTimeout time.Duration + + // CommandQueueTimeout is accepted but unused. + // + // Deprecated: 离线命令队列(生产不可达路径)已随死代码清理移除;保留本 flag 仅为不破坏既有启动脚本,下个版本删除。 + CommandQueueTimeout time.Duration } func Load() *Config { @@ -51,7 +55,7 @@ func Load() *Config { flag.IntVar(&cfg.WebSocketWriteQueueSize, "websocket-write-queue-size", getenvInt("LIVEAGENT_GATEWAY_WS_WRITE_QUEUE_SIZE", 512), "write queue buffer size for browser WebSocket connections") flag.IntVar(&cfg.GRPCMaxMessageBytes, "grpc-max-message-bytes", getenvInt("LIVEAGENT_GATEWAY_GRPC_MAX_MESSAGE_BYTES", DefaultGRPCMaxMessageBytes), "maximum gRPC message size in bytes") flag.IntVar(&cfg.RelayBufferSeconds, "relay-buffer-seconds", getenvInt("LIVEAGENT_GATEWAY_RELAY_BUFFER_SECONDS", 30), "seconds of chat events to buffer for brief reconnections") - flag.DurationVar(&cfg.CommandQueueTimeout, "command-queue-timeout", getenvDuration("LIVEAGENT_GATEWAY_COMMAND_QUEUE_TIMEOUT", 30*time.Second), "timeout for queuing commands when agent is temporarily offline") + flag.DurationVar(&cfg.CommandQueueTimeout, "command-queue-timeout", getenvDuration("LIVEAGENT_GATEWAY_COMMAND_QUEUE_TIMEOUT", 30*time.Second), "deprecated, no-op (kept for startup-script compatibility)") flag.Parse() cfg.Token = strings.TrimSpace(cfg.Token) diff --git a/crates/agent-gateway/internal/server/grpc.go b/crates/agent-gateway/internal/server/grpc.go index f16534cb7..38a67fc5e 100644 --- a/crates/agent-gateway/internal/server/grpc.go +++ b/crates/agent-gateway/internal/server/grpc.go @@ -4,16 +4,22 @@ import ( "context" "errors" "io" + "log/slog" "strings" "time" "github.com/google/uuid" "github.com/liveagent/agent-gateway/internal/config" + "github.com/liveagent/agent-gateway/internal/observability" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" "github.com/liveagent/agent-gateway/internal/session" ) +// GRPCServer implements the v1 AgentGateway gRPC service for the desktop +// agent link. +// +// Deprecated: v1 gRPC 链路已被 v2 /ws/v2/agent(WebSocket+Protobuf,internal/protocol/pbws)取代,仅为旧版桌面客户端保留;流量归零后连同 gRPC 监听与拦截器一并删除。 type GRPCServer struct { gatewayv1.UnimplementedAgentGatewayServer @@ -21,6 +27,9 @@ type GRPCServer struct { sm *session.Manager } +// NewGRPCServer constructs the v1 gRPC service implementation. +// +// Deprecated: 见 GRPCServer。 func NewGRPCServer(cfg *config.Config, sm *session.Manager) *GRPCServer { return &GRPCServer{ cfg: cfg, @@ -48,6 +57,12 @@ func (s *GRPCServer) Authenticate(_ context.Context, req *gatewayv1.AuthRequest) } func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServer) error { + // v1 使用打点:gRPC agent 链路已被 /ws/v2/agent 取代,观察归零后删除。 + observability.Usage.V1GRPCAgentConnectsTotal.Add(1) + observability.Usage.V1GRPCAgentActive.Add(1) + defer observability.Usage.V1GRPCAgentActive.Add(-1) + slog.Warn("deprecated v1 gRPC agent stream established") + authSnapshot := s.sm.LatestAuthSnapshot() sess := session.NewAgentSession(authSnapshot) toAgent := sess.Outbound() @@ -149,6 +164,9 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ } func (s *GRPCServer) AgentTerminalConnect(stream gatewayv1.AgentGateway_AgentTerminalConnectServer) error { + observability.Usage.V1GRPCTerminalConnectsTotal.Add(1) + slog.Warn("deprecated v1 gRPC terminal stream established") + toAgent := make(chan *gatewayv1.TerminalStreamFrame, 4096) cleanup := s.sm.RegisterTerminalStreamToAgent(toAgent) defer cleanup() diff --git a/crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go b/crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go index 72171a12a..0591fe4f3 100644 --- a/crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_cron_handlers.go b/crates/agent-gateway/internal/server/websocket_cron_handlers.go index ead7ae3b8..acf158030 100644 --- a/crates/agent-gateway/internal/server/websocket_cron_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_cron_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_fs_handlers.go b/crates/agent-gateway/internal/server/websocket_fs_handlers.go index 9c8c7b3f5..d21a597c5 100644 --- a/crates/agent-gateway/internal/server/websocket_fs_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_fs_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_git_handlers.go b/crates/agent-gateway/internal/server/websocket_git_handlers.go index 1c0981709..d6e2d8986 100644 --- a/crates/agent-gateway/internal/server/websocket_git_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_git_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_memory_handlers.go b/crates/agent-gateway/internal/server/websocket_memory_handlers.go index b49e1cd09..d30f87505 100644 --- a/crates/agent-gateway/internal/server/websocket_memory_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_memory_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_payloads.go b/crates/agent-gateway/internal/server/websocket_payloads.go index a42281c9f..66ed56e55 100644 --- a/crates/agent-gateway/internal/server/websocket_payloads.go +++ b/crates/agent-gateway/internal/server/websocket_payloads.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_process_handlers.go b/crates/agent-gateway/internal/server/websocket_process_handlers.go index 354a411d5..e8abf03ed 100644 --- a/crates/agent-gateway/internal/server/websocket_process_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_process_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_provider_handlers.go b/crates/agent-gateway/internal/server/websocket_provider_handlers.go index e1773b2f1..024bebf22 100644 --- a/crates/agent-gateway/internal/server/websocket_provider_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_provider_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_routes.go b/crates/agent-gateway/internal/server/websocket_routes.go index 9cf12f5fa..f26091282 100644 --- a/crates/agent-gateway/internal/server/websocket_routes.go +++ b/crates/agent-gateway/internal/server/websocket_routes.go @@ -1,7 +1,13 @@ package server +// websocketRequestHandler is a v1 JSON protocol request handler. +// +// Deprecated: v1 JSON 协议的分发机制,已被 v2 的 GatewayEnvelope 直通 + 白名单校验(internal/protocol/pbws/guard.go)取代。 type websocketRequestHandler func(*websocketConnection, websocketRequest) +// websocketRequestHandlers maps v1 op strings to handlers. +// +// Deprecated: 见 websocketRequestHandler;随 v1 一并删除。 var websocketRequestHandlers = map[string]websocketRequestHandler{ "status.get": func(c *websocketConnection, req websocketRequest) { _ = c.writeResponse(req.ID, c.sm.Status()) diff --git a/crates/agent-gateway/internal/server/websocket_settings_handlers.go b/crates/agent-gateway/internal/server/websocket_settings_handlers.go index 1c20c7fb5..9a4317037 100644 --- a/crates/agent-gateway/internal/server/websocket_settings_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_settings_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_sftp_handlers.go b/crates/agent-gateway/internal/server/websocket_sftp_handlers.go index 4849b8556..febcf414a 100644 --- a/crates/agent-gateway/internal/server/websocket_sftp_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_sftp_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_skills_handlers.go b/crates/agent-gateway/internal/server/websocket_skills_handlers.go index 920729287..570af8672 100644 --- a/crates/agent-gateway/internal/server/websocket_skills_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_skills_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go b/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go index f5e9da49c..969dfb1e3 100644 --- a/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_upload_handlers.go b/crates/agent-gateway/internal/server/websocket_upload_handlers.go index ce55aecd0..422e28a5b 100644 --- a/crates/agent-gateway/internal/server/websocket_upload_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_upload_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( diff --git a/crates/agent-gateway/internal/server/websocket_workspace_handlers.go b/crates/agent-gateway/internal/server/websocket_workspace_handlers.go index 33fb1fa50..ebda976ff 100644 --- a/crates/agent-gateway/internal/server/websocket_workspace_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_workspace_handlers.go @@ -1,3 +1,4 @@ +// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 package server import ( From bb5e31510e6d875ffcfd92b1d5f3e2fea66852d4 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:21:40 +0800 Subject: [PATCH 09/10] refactor(session): delete production-dead send/queue APIs SendToAgent, RegisterStream, SendToAgentOrQueue and the offline command queue had no production callers (queue enqueue was unreachable); tests migrate to the context-aware variants --- .../internal/server/grpc_test.go | 27 ++-- .../internal/session/command_queue.go | 116 ------------------ .../agent-gateway/internal/session/manager.go | 5 - .../internal/session/manager_registry.go | 57 --------- .../test/session/manager_test.go | 31 +++-- 5 files changed, 29 insertions(+), 207 deletions(-) delete mode 100644 crates/agent-gateway/internal/session/command_queue.go diff --git a/crates/agent-gateway/internal/server/grpc_test.go b/crates/agent-gateway/internal/server/grpc_test.go index 7bf5621cf..09d53b8f3 100644 --- a/crates/agent-gateway/internal/server/grpc_test.go +++ b/crates/agent-gateway/internal/server/grpc_test.go @@ -176,21 +176,18 @@ func TestAgentConnectDispatchesCorrelatedPong(t *testing.T) { } const requestID = "chat-runtime-wake-correlated" - responses, done, cleanup, err := sm.RegisterStream(requestID) + // RegisterStreamAndSendContext 在 gRPC 泵 Ack 送达后返回;服务端 AgentConnect 泵已在运行,可内联调用。 + responses, done, cleanup, err := sm.RegisterStreamAndSendContext(ctx, requestID, &gatewayv1.GatewayEnvelope{ + RequestId: requestID, + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_Ping{ + Ping: &gatewayv1.PingRequest{Timestamp: time.Now().Unix()}, + }, + }) if err != nil { t.Fatalf("register correlated response stream: %v", err) } defer cleanup() - sendResult := make(chan error, 1) - go func() { - sendResult <- sm.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ - RequestId: requestID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_Ping{ - Ping: &gatewayv1.PingRequest{Timestamp: time.Now().Unix()}, - }, - }) - }() probe, err := stream.Recv() if err != nil { @@ -219,12 +216,4 @@ func TestAgentConnectDispatchesCorrelatedPong(t *testing.T) { case <-ctx.Done(): t.Fatalf("timed out waiting for correlated Pong: %v", ctx.Err()) } - select { - case err := <-sendResult: - if err != nil { - t.Fatalf("send correlated probe: %v", err) - } - case <-ctx.Done(): - t.Fatalf("timed out waiting for probe delivery ack: %v", ctx.Err()) - } } diff --git a/crates/agent-gateway/internal/session/command_queue.go b/crates/agent-gateway/internal/session/command_queue.go deleted file mode 100644 index 7fa80e03c..000000000 --- a/crates/agent-gateway/internal/session/command_queue.go +++ /dev/null @@ -1,116 +0,0 @@ -package session - -import ( - "context" - "sync" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -const ( - defaultCommandQueueTimeout = 30 * time.Second - maxCommandQueueSize = 10 -) - -type pendingCommand struct { - envelope *gatewayv1.GatewayEnvelope - result chan error - deadline time.Time -} - -type commandQueue struct { - mu sync.Mutex - items []pendingCommand - timeout time.Duration -} - -func newCommandQueue(timeout time.Duration) *commandQueue { - if timeout <= 0 { - timeout = defaultCommandQueueTimeout - } - return &commandQueue{ - timeout: timeout, - } -} - -func (q *commandQueue) Enqueue(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { - q.mu.Lock() - q.evictExpiredLocked() - if len(q.items) >= maxCommandQueueSize { - q.mu.Unlock() - return ErrCommandQueueFull - } - resultCh := make(chan error, 1) - q.items = append(q.items, pendingCommand{ - envelope: env, - result: resultCh, - deadline: time.Now().Add(q.timeout), - }) - q.mu.Unlock() - - select { - case err := <-resultCh: - return err - case <-ctx.Done(): - return ctx.Err() - case <-time.After(q.timeout): - return ErrCommandQueueTimeout - } -} - -func (q *commandQueue) DrainTo(session *AgentSession) { - q.mu.Lock() - items := q.items - q.items = nil - q.mu.Unlock() - - now := time.Now() - for _, cmd := range items { - if now.After(cmd.deadline) { - select { - case cmd.result <- ErrCommandQueueTimeout: - default: - } - continue - } - ctx, cancel := context.WithDeadline(context.Background(), cmd.deadline) - err := session.SendToAgentContext(ctx, cmd.envelope) - cancel() - select { - case cmd.result <- err: - default: - } - } -} - -func (q *commandQueue) FailAll(err error) { - q.mu.Lock() - items := q.items - q.items = nil - q.mu.Unlock() - - for _, cmd := range items { - select { - case cmd.result <- err: - default: - } - } -} - -func (q *commandQueue) evictExpiredLocked() { - now := time.Now() - n := 0 - for _, cmd := range q.items { - if now.After(cmd.deadline) { - select { - case cmd.result <- ErrCommandQueueTimeout: - default: - } - continue - } - q.items[n] = cmd - n++ - } - q.items = q.items[:n] -} diff --git a/crates/agent-gateway/internal/session/manager.go b/crates/agent-gateway/internal/session/manager.go index 531fe12b2..94a97703a 100644 --- a/crates/agent-gateway/internal/session/manager.go +++ b/crates/agent-gateway/internal/session/manager.go @@ -12,8 +12,6 @@ var ErrAgentOffline = errors.New("agent offline") var ErrTunnelNotFound = errors.New("tunnel not found") var ErrTunnelExpired = errors.New("tunnel expired") var ErrTunnelOverLimit = errors.New("tunnel connection limit exceeded") -var ErrCommandQueueFull = errors.New("command queue full") -var ErrCommandQueueTimeout = errors.New("command queue timeout: agent did not reconnect") const ( chatRuntimeReadyTTL = 15 * time.Second @@ -32,7 +30,6 @@ type Manager struct { syncHub *syncHub convStreams *conversationStreamStore tunnels *tunnelRuntime - cmdQueue *commandQueue workspaceHub *workspaceActivityHub managedProcesses *managedProcessHub statusSubs *statusSubscriberHub @@ -83,7 +80,6 @@ func NewManager() *Manager { registry: newSessionRegistry(), syncHub: newSyncHub(), tunnels: newTunnelRuntime(), - cmdQueue: newCommandQueue(defaultCommandQueueTimeout), workspaceHub: newWorkspaceActivityHub(), managedProcesses: newManagedProcessHub(), statusSubs: newStatusSubscriberHub(), @@ -92,4 +88,3 @@ func NewManager() *Manager { go m.tunnelExpirySweepLoop() return m } - diff --git a/crates/agent-gateway/internal/session/manager_registry.go b/crates/agent-gateway/internal/session/manager_registry.go index 1aa528a3c..cd0bf3e5b 100644 --- a/crates/agent-gateway/internal/session/manager_registry.go +++ b/crates/agent-gateway/internal/session/manager_registry.go @@ -2,7 +2,6 @@ package session import ( "context" - "errors" "strings" "time" @@ -55,7 +54,6 @@ func (m *Manager) SetSession(s *AgentSession) { previous.Close() } if s != nil && sessionChanged { - m.cmdQueue.DrainTo(s) // Replay the watched-workdir set: a freshly connected agent starts // with an empty watch set and only learns a non-empty one from this // push. An empty set needs no replay. @@ -112,23 +110,6 @@ func (m *Manager) ClearSessionIfHeartbeatStale(session *AgentSession, timeout ti 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() - go m.onAgentSessionCleared() - return true -} - func (m *Manager) Status() Status { m.registry.mu.RLock() defer m.registry.mu.RUnlock() @@ -293,16 +274,6 @@ func normalizeRuntimeState(state string) string { } } -func (m *Manager) SendToAgent(env *gatewayv1.GatewayEnvelope) error { - m.registry.mu.RLock() - session := m.registry.session - m.registry.mu.RUnlock() - if session == nil { - return ErrAgentOffline - } - return session.SendToAgent(env) -} - func (m *Manager) SendToAgentContext(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { m.registry.mu.RLock() session := m.registry.session @@ -313,40 +284,12 @@ func (m *Manager) SendToAgentContext(ctx context.Context, env *gatewayv1.Gateway return session.SendToAgentContext(ctx, env) } -func (m *Manager) SendToAgentOrQueue(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { - err := m.SendToAgentContext(ctx, env) - if errors.Is(err, ErrAgentOffline) { - return m.cmdQueue.Enqueue(ctx, env) - } - return err -} - func (m *Manager) currentSessionEpoch() uint64 { m.registry.mu.RLock() defer m.registry.mu.RUnlock() return m.registry.sessionEpoch } -func (m *Manager) RegisterStream(requestID string) (<-chan *gatewayv1.AgentEnvelope, <-chan struct{}, func(), error) { - m.registry.mu.RLock() - session := m.registry.session - m.registry.mu.RUnlock() - if session == nil { - return nil, nil, nil, ErrAgentOffline - } - - stream, err := session.registerStream(requestID) - if err != nil { - return nil, nil, nil, err - } - - cleanup := func() { - session.unregisterStream(requestID, stream) - } - - return stream.ch, stream.done, cleanup, nil -} - // RegisterStreamAndSendContext binds response correlation and request delivery // to the same AgentSession instance. Calling RegisterStream followed by // Manager.SendToAgentContext performs two independent current-session lookups; diff --git a/crates/agent-gateway/test/session/manager_test.go b/crates/agent-gateway/test/session/manager_test.go index ac04bb67f..280626dc4 100644 --- a/crates/agent-gateway/test/session/manager_test.go +++ b/crates/agent-gateway/test/session/manager_test.go @@ -75,24 +75,30 @@ func TestClearSessionDoesNotCloseReplacement(t *testing.T) { assertDoneOpen(t, second.Done()) env := &gatewayv1.GatewayEnvelope{RequestId: "still-current"} - if err := sm.SendToAgent(env); err != nil { - t.Fatalf("SendToAgent after stale ClearSession: %v", err) - } + // SendToAgentContext 等待送达 Ack;在旁路读取并 Ack 出站信封后再收敛。 + sendErr := make(chan error, 1) + go func() { + sendErr <- sm.SendToAgentContext(context.Background(), env) + }() select { case got := <-second.Outbound(): + got.Ack(nil) if got.GetRequestId() != "still-current" { t.Fatalf("request id = %q, want still-current", got.GetRequestId()) } case <-time.After(time.Second): t.Fatalf("timed out waiting for current session outbound message") } + if err := <-sendErr; err != nil { + t.Fatalf("SendToAgentContext after stale ClearSession: %v", err) + } sm.ClearSession(second) assertDoneClosed(t, second.Done()) if status := sm.Status(); status.Online { t.Fatalf("status online = true after clearing current session") } - if err := sm.SendToAgent(env); !errors.Is(err, session.ErrAgentOffline) { + if err := sm.SendToAgentContext(context.Background(), env); !errors.Is(err, session.ErrAgentOffline) { t.Fatalf("SendToAgent after clearing current session = %v, want ErrAgentOffline", err) } } @@ -123,7 +129,7 @@ func TestClearSessionIfHeartbeatStaleClosesOnlyCurrentSession(t *testing.T) { 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) { + if err := sm.SendToAgentContext(context.Background(), &gatewayv1.GatewayEnvelope{RequestId: "after-timeout"}); !errors.Is(err, session.ErrAgentOffline) { t.Fatalf("SendToAgent after heartbeat timeout = %v, want ErrAgentOffline", err) } } @@ -230,9 +236,14 @@ func TestDispatchFromStaleSessionIsIgnored(t *testing.T) { second := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(second) - ch, done, cleanup, err := sm.RegisterStream("request-1") + // RegisterStreamAndSendContext 等待送达 Ack;没有服务泵,用一次性 drain 代替。 + go func() { + outbound := <-second.Outbound() + outbound.Ack(nil) + }() + ch, done, cleanup, err := sm.RegisterStreamAndSendContext(context.Background(), "request-1", &gatewayv1.GatewayEnvelope{RequestId: "request-1"}) if err != nil { - t.Fatalf("RegisterStream: %v", err) + t.Fatalf("RegisterStreamAndSendContext: %v", err) } defer cleanup() @@ -285,7 +296,7 @@ func TestSendToAgentUnblocksWhenSessionCloses(t *testing.T) { } }() for i := 0; i < 128; i += 1 { - _ = sm.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("request-%d", i)}) + _ = sm.SendToAgentContext(context.Background(), &gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("request-%d", i)}) } errCh <- nil }() @@ -311,7 +322,7 @@ func TestSendToAgentContextTimeoutKeepsSessionAlive(t *testing.T) { sm.SetSession(sess) for i := 0; i < cap(sess.Outbound()); i += 1 { - if err := sm.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("queued-%d", i)}); err != nil { + if err := sess.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("queued-%d", i)}); err != nil { t.Fatalf("prime outbound queue: %v", err) } } @@ -341,7 +352,7 @@ func TestSendPingBypassesFullOutboundQueue(t *testing.T) { sm.SetSession(sess) for i := 0; i < cap(sess.Outbound()); i += 1 { - if err := sm.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("queued-%d", i)}); err != nil { + if err := sess.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("queued-%d", i)}); err != nil { t.Fatalf("prime outbound queue: %v", err) } } From 95cec273f2939aeae1293322d44978ac66667897 Mon Sep 17 00:00:00 2001 From: yyg-max <175597134+yyg-max@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:22:03 +0800 Subject: [PATCH 10/10] docs: document v2 protocol as primary contract with v1 removal plan - protocols.md rewritten around the unified WebSocket+Protobuf links, v1 kept as a deprecated appendix; gateway/webui/gui/overview updated - new protocol-v2-migration.md: skew matrix, zero-traffic criteria, step-by-step v1 removal checklist - development.md: gateway layering map, capability-addition walkthrough, deprecation conventions, lint/proto-check commands --- docs/architecture/gateway.md | 62 ++++---- docs/architecture/gui.md | 6 +- docs/architecture/overview.md | 16 +- docs/architecture/protocol-v2-migration.md | 69 ++++++++ docs/architecture/protocols.md | 174 ++++++++++++++------- docs/architecture/webui.md | 4 +- docs/operations/development.md | 20 ++- docs/reference/source-map.md | 13 +- 8 files changed, 264 insertions(+), 100 deletions(-) create mode 100644 docs/architecture/protocol-v2-migration.md diff --git a/docs/architecture/gateway.md b/docs/architecture/gateway.md index c66f4ee4f..5342bce38 100644 --- a/docs/architecture/gateway.md +++ b/docs/architecture/gateway.md @@ -2,36 +2,35 @@ ## 职责边界 -Gateway 是远程访问中继,不是 Agent 执行环境。它同时面对桌面 Agent 和浏览器 WebUI: +Gateway 是远程访问中继,不是 Agent 执行环境。它同时面对桌面 Agent 和浏览器 WebUI, +v2 起两端统一走 WebSocket+Protobuf(v1 gRPC 与 JSON WebSocket 已弃用、仅为旧客户端保留): | 方向 | 协议 | 作用 | |---|---|---| -| Desktop Agent -> Gateway | gRPC `AgentGateway.AgentConnect` 双向流 | 桌面端注册在线 session,接收 WebUI 请求,返回 chat/history/settings/memory/skills 等响应与事件。 | -| Desktop Agent -> Gateway | gRPC `AgentGateway.AgentTerminalConnect` 双向流 | 专用终端字节流,承载 attach snapshot、input、resize、output,不与普通控制面共享队列。 | -| WebUI -> Gateway | WebSocket `/ws` | 浏览器端发起 chat(command/subscribe)、history、settings、skills、memory、cron 等 request,并订阅 `chat.event` 与同步广播。 | -| WebUI -> Gateway | WebSocket `/ws/terminal` | 专用二进制终端流;输入 fire-and-forget,输出按 session interest 推送轻量 bytes frame。 | +| Desktop Agent -> Gateway | WebSocket `/ws/v2/agent`(Protobuf 帧) | 桌面端注册在线 session,接收 WebUI 请求(`GatewayEnvelope`),返回 chat/history/settings/memory/skills 等响应与事件(`AgentEnvelope`)。 | +| Desktop/Browser -> Gateway | WebSocket `/ws/v2/terminal`(Protobuf 帧) | 专用终端字节流(角色由 hello 区分),承载 attach snapshot、input、resize、output,不与普通控制面共享队列。 | +| WebUI -> Gateway | WebSocket `/ws/v2`(Protobuf 帧) | 浏览器端发起 chat(command/subscribe)、直通 history/settings/skills/memory/cron 等请求,并订阅 `chat_event` 与同步广播。 | | WebUI -> Gateway | HTTP `/api/*` | 状态检查、文件上传、公网分享页、图片代理、静态资源。 | +| ~~Desktop -> Gateway~~ | gRPC `AgentGateway.*` | **已弃用**,服务旧版桌面端。 | +| ~~WebUI -> Gateway~~ | WebSocket `/ws`、`/ws/terminal` | **已弃用**,服务未刷新的旧标签页。 | ## 入口与服务启动 | 文件 | 作用 | |---|---| -| `cmd/gateway/main.go` | 读取 config,创建 `session.Manager`,启动 gRPC server 与 HTTP server,处理 shutdown。 | +| `cmd/gateway/main.go` | 读取 config,创建 `session.Manager`,启动 gRPC server(弃用,兼容旧桌面端)与 HTTP server,处理 shutdown。 | | `cmd/gateway/shutdown.go` | gRPC graceful stop 超时后强制 stop。 | | `internal/config/config.go` | 地址、token、TLS、静态资源、请求大小、超时等配置。 | -| `internal/auth/grpc_interceptor.go` | gRPC token 校验。 | -| `internal/auth/http_middleware.go` | HTTP API token 校验。 | -| `internal/server/grpc.go` | `AgentGateway` gRPC 服务实现。 | -| `internal/server/http.go` | HTTP mux、WebSocket、API、静态 WebUI 与 public share route。 | -| `internal/server/websocket_chat_handlers.go`、`websocket_chat_queue_handlers.go`、`chat_commands.go` | WS `chat.prepare`/`chat.command`/`chat.subscribe`/`chat.cancel`/chat queue handler、原生 Runtime 往返探测、订阅 replay 与实时 forward、Gateway -> Desktop `ChatCommandRequest` 下发与事件 payload 映射。 | -| `internal/server/websocket.go` | WebUI WebSocket 连接生命周期、鉴权、订阅 forwarder。 | -| `internal/server/websocket_routes.go` | WebSocket request type 到 domain handler 的路由表。 | -| `internal/server/websocket_*_handlers.go` | fs/history/settings/terminal/git/skills/memory/cron/provider 等非 Chat domain handler。 | -| `internal/server/websocket_payloads.go` | WebSocket 响应 payload 组装与 JSON helper。 | -| `internal/server/websocket_roundtrip.go` | payload 严格解码、Agent unary round-trip 和错误文案归一。 | -| `internal/server/websocket_writer.go` | WebSocket 并发写锁、write deadline 与 envelope 发送。 | -| `internal/server/websocket_connection_state.go` | 单条 WebSocket 连接内的 terminal interest 状态。 | -| `internal/session/manager.go` | `session.Manager` façade 和核心公开类型。 | +| `internal/observability/` | slog 初始化与 v1/v2 协议使用计数(`/api/status` 的 `protocol_usage`)。 | +| `internal/transport/wscore/` | v1/v2 共用的 WebSocket 连接运行时:控制优先双队列写泵、拥塞掉帧、有限重试、心跳与空闲驱逐。 | +| `internal/protocol/pbws/` | **v2 协议层**:三链路握手/编解码、直通白名单(`guard.go`)、关联 id 命名空间化、事件扇出与快照回放。 | +| `internal/protocol/shared/` | 两代协议共用的域逻辑:Origin 校验、终端权限门控与响应后处理、终端兴趣跟踪。 | +| `internal/chatcmd/` | chat 命令编排(归一化、探活、投递、启动看门狗),v1/v2 共用。 | +| `internal/auth/` | HTTP/WS token 校验;gRPC 拦截器(弃用)。 | +| `internal/server/grpc.go` | v1 `AgentGateway` gRPC 服务实现(弃用)。 | +| `internal/server/http.go` | HTTP mux、v1/v2 WebSocket 路由、API、静态 WebUI 与 public share route。 | +| `internal/server/websocket*.go` | v1 JSON 协议实现(弃用):连接生命周期、字符串路由表、约 90 个 domain handler 与 payload 塑形。 | +| `internal/session/manager.go` | `session.Manager` façade 和核心公开类型(transport 无关,两代协议共用)。 | | `internal/session/manager_state.go` | session registry、sync hub、chat run store 的内部状态定义。 | | `internal/session/manager_registry.go` | 当前 Agent session、认证快照、per-request stream 注册。 | | `internal/session/manager_*_sync.go`、`manager_terminal.go`、`conversation_stream.go`、`conversation_ingress.go` | history/settings/terminal sync、进程内 Chat 事件窗口、实时 fan-out、replay 与 command dedupe。 | @@ -40,25 +39,30 @@ Gateway 是远程访问中继,不是 Agent 执行环境。它同时面对桌 | 路由 | 认证 | 说明 | |---|---|---| -| `GET /ws` | token | WebUI 主 WebSocket 协议。 | -| `GET /ws/terminal` | token | WebUI 终端专用 WebSocket;首帧 JSON auth,后续使用 `version + kind + headerLength + JSON header + bytes` 的二进制 frame。 | -| `GET /api/status` | token | Gateway 当前 Agent 在线状态。 | +| `GET /ws/v2` | hello token | **v2** WebUI 主链路(Protobuf 帧,子协议 `liveagent.v2.pb`)。 | +| `GET /ws/v2/agent` | hello token | **v2** 桌面端信封流(取代 gRPC)。 | +| `GET /ws/v2/terminal` | hello token | **v2** 终端数据面(两端共用,角色在 hello)。 | +| `GET /ws` | token | v1 WebUI JSON 协议(弃用)。 | +| `GET /ws/terminal` | token | v1 终端二进制流(弃用);首帧 JSON auth,后续 `version + kind + headerLength + JSON header + bytes`。 | +| `GET /api/status` | token | Agent 在线状态 + `protocol_usage` 协议使用计数。 | | `POST /api/files/import` | token | WebUI 上传可读文件,Gateway 转发给桌面端导入 workspace uploads。 | | `GET /api/public/history-shares/{token}` | public token | 公开只读历史分享数据。 | | `GET /image-proxy` | 视配置/实现而定 | 图片代理,带 URL 安全校验。 | | `/` | 无或按静态资源策略 | 嵌入/构建后的 WebUI 静态资源与 SPA fallback。 | -Chat 走 `/ws` 且是严格新协议:`chat.prepare` 用关联 Ping/Pong 探测并唤醒桌面 Chat Runtime;`chat.command` 必须是 `{ type, payload }` envelope,`chat.subscribe` 按 `conversation_id` 订阅,不接受旧 `command` 字段、顶层裸 payload 或 `request_id` 别名。旧 HTTP SSE 路由 `GET /api/chat/events` 已下线。 +Chat 走 `/ws/v2` 且是严格新协议:`chat_prepare` 用关联 Ping/Pong 探测并唤醒桌面 Chat Runtime;`chat_command` 携带 proto `ChatCommandRequest`,`chat_subscribe` 按 `conversation_id` 订阅。旧 HTTP SSE 路由 `GET /api/chat/events` 已下线。 -## gRPC 服务 +## gRPC 服务(已弃用) + +仅为旧版桌面端保留;新桌面端走 `/ws/v2/agent`,握手失败时自动回退到本服务。 | RPC | 类型 | 用途 | |---|---|---| -| `Authenticate(AuthRequest) -> AuthResponse` | unary | 桌面端认证探活,返回 session 信息。 | -| `AgentConnect(stream AgentEnvelope) -> stream GatewayEnvelope` | bidirectional stream | 桌面端常驻连接,WebUI request 下发为 `GatewayEnvelope`,桌面端 response/event 回传为 `AgentEnvelope`。 | -| `AgentTerminalConnect(stream TerminalStreamFrame) -> stream TerminalStreamFrame` | bidirectional stream | 终端专用字节流;attach 返回 snapshot,input/resize 不返回 session metadata,output 只携带 session、offset 与 bytes。 | +| `Authenticate(AuthRequest) -> AuthResponse` | unary | 桌面端认证探活,返回 session 信息(v2 由 hello 承担)。 | +| `AgentConnect(stream AgentEnvelope) -> stream GatewayEnvelope` | bidirectional stream | 桌面端常驻连接(v2 由 `/ws/v2/agent` 承担,信封同构)。 | +| `AgentTerminalConnect(stream TerminalStreamFrame) -> stream TerminalStreamFrame` | bidirectional stream | 终端专用字节流(v2 由 `/ws/v2/terminal` 承担)。 | -`proto/v1/gateway.proto` 是 Desktop 与 Gateway 的权威协议定义;Go 侧生成文件位于 `internal/proto/v1/*`。 +`proto/v1/gateway.proto` 是三端共享的权威业务消息定义(Go 生成于 `internal/proto/v1/*`);v2 帧壳定义于 `proto/v2/gateway_ws.proto`(Go 生成于 `internal/proto/v2/*`)。代码生成统一由 `buf` 驱动(`make proto`),CI 有生成物漂移与 breaking 检查门禁。 ## Session Manager @@ -91,7 +95,7 @@ Chat 走 `/ws` 且是严格新协议:`chat.prepare` 用关联 Ping/Pong 探测 | chat command | 提交/编辑/取消走 WS `chat.command`;新 command 在 accepted 前必须有原生往返 probe,紧随成功 prepare 的 command 可复用同一 session 2 秒内的新鲜结果,旧客户端或过期结果仍现场探测。accepted ACK 走控制优先队列,避免被 token 数据积压阻塞。流式事件走按 conversation 持久订阅 `chat.subscribe`,经 `chat.event` 推送(订阅缓冲溢出时发 `chat.subscription_reset` 提示客户端按游标重订阅)。 | | terminal stream | 不走普通 `/ws` request;attach/input/resize/detach 走 `/ws/terminal` 二进制 frame,页面侧 `BrowserGatewayTerminalStreamClient` 为同一 token 复用一条 terminal stream 并按 session fan-out。 | -WebSocket server 的实现按三层组织:`websocket.go` 管连接生命周期和订阅 forwarder;`websocket_routes.go` 管 request 路由;`websocket_*_handlers.go` 管 domain handler。handler 只做 payload 校验、调用 Gateway/Desktop service、组装 WebUI 响应。连接内的可变状态不再直接铺在 `websocketConnection` 上,而是通过 terminal tracker 管理。 +WebSocket server 的实现分层:`internal/transport/wscore` 管写泵/背压/心跳(两代共用);v2 在 `internal/protocol/pbws`(帧编解码、直通白名单、事件扇出);v1 在 `internal/server/websocket*.go`(弃用:连接生命周期 + 字符串路由 + domain handler)。域逻辑(终端门控/响应后处理、chat 编排)在 `internal/protocol/shared` 与 `internal/chatcmd`,两代协议调用同一份实现。 Terminal metadata 事件仍通过普通 `/ws` 广播,用于同步 `created`、`exit`、`closed`、`renamed`、SSH prompt 和 SSH tab 状态;terminal output 不再进入 React session state,也不再附带完整 session。输出 bytes 只通过 `/ws/terminal` 和 `AgentTerminalConnect` 推送,慢客户端只阻塞自己的 terminal stream。 diff --git a/docs/architecture/gui.md b/docs/architecture/gui.md index 557f25a00..e61c43c41 100644 --- a/docs/architecture/gui.md +++ b/docs/architecture/gui.md @@ -79,9 +79,9 @@ | 机制 | 当前实现 | |---|---| | 稳定 WebView listener | `useGatewayBridgeListeners` 用 ref 保存 worker id 和最新回调,effect 只在组件挂载/卸载时注册或销毁;普通 React render 不再重建 listener、制造接收空窗或重复上报 `suspended`。 | -| 原生往返唤醒 | Rust 收到 `chat-runtime-wake-` 前缀的关联 Ping 后 emit `gateway:chat-runtime-wake`;所有 Pong(唤醒与心跳)经专用出站控制通道(64 深,与数据队列 merge 进同一 gRPC stream)`try_send` 返回,token 流打满数据队列时探测仍可被应答,且绝不阻塞 inbound receive loop。 | +| 原生往返唤醒 | Rust 收到 `chat-runtime-wake-` 前缀的关联 Ping 后 emit `gateway:chat-runtime-wake`;所有 Pong(唤醒与心跳)经专用出站控制通道(64 深,与数据队列 merge 进同一信封流(v2 WebSocket;旧网关回退 gRPC))`try_send` 返回,token 流打满数据队列时探测仍可被应答,且绝不阻塞 inbound receive loop。 | | 生命周期 nudge | `online`、`focus`、`pageshow`、`visibilitychange`、WebView `resume` 与 Tauri `RunEvent::Resumed` 会唤醒 runtime;`online`/focus 类事件经 `gateway_nudge_connection` 走 offline/stale-heartbeat 健康检查后才重建连接(不强制),仅 `RunEvent::Resumed` 保留强制重连。 | -| 快速重连 | gRPC 自动重连从 250ms 指数退避到 5s,稳定连接 30s 后重置;stale 判断使用 heartbeat interval 加 20s(最多 60s)。 | +| 快速重连 | 信封流自动重连从 250ms 指数退避到 5s(每次重连先试 v2 `/ws/v2/agent`,握手失败回退 gRPC),稳定连接 30s 后重置;stale 判断使用 heartbeat interval 加 20s(最多 60s)。 | | inbound 优先 | `AgentConnect` 建立后立即进入 inbound receive loop。Runtime status 先恢复,settings、terminal、tunnel、process 与 run ledger 延迟 200ms 后在可中止后台任务中低优先级 replay,并在批次间 yield。 | | 启动空窗消除 | WebView 在 Tauri listener 异步注册完成前就先 heartbeat + drain 一次;native wake、request-ready 与 Gateway online 事件都会继续触发 drain。 | @@ -101,7 +101,7 @@ | 取舍 | 原因 | |---|---| | ChatPage 仍是总编排层 | 对话运行时跨模型、工具、历史、压缩、记忆、Gateway、上传和 UI 状态,保留一个编排中心能减少跨模块隐式状态。 | -| 高权限能力放 Rust | 文件系统、Shell、MCP 进程、SQLite、Gateway gRPC、Cron 更适合在 Tauri 后端做权限与生命周期控制。 | +| 高权限能力放 Rust | 文件系统、Shell、MCP 进程、SQLite、Gateway 连接、Cron 更适合在 Tauri 后端做权限与生命周期控制。 | | GUI 与 WebUI 复制部分 UI | 两端运行环境不同,WebUI 不能直接调用 Tauri,但需要维持体验 parity,因此复制 settings/hub/chat 组件并接入 shims。 | | Settings 按域保存 | provider secret、remote、cron、memory 等域有不同验证和同步策略,分域保存便于限制泄露与减少误覆盖。 | | Gateway 控制面优先 | 远程首条 Chat command 与 Ping/Pong 必须先于大体积状态 reconciliation;后台 snapshot replay 只负责最终一致性,不阻塞 inbound。 | diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 9791fe314..e7b0ad9a0 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -7,7 +7,7 @@ | 桌面 GUI | `crates/agent-gui/src` | React、TypeScript、Vite、Tailwind | Chat shell、Settings、Skills Hub、MCP Hub、Memory UI、历史侧边栏、上传与流式渲染。 | | 桌面后端 | `crates/agent-gui/src-tauri/src` | Tauri 2、Rust、SQLite、tokio | 系统命令、文件/Shell/进程、MCP runtime、MemoryStore、CronManager、GatewayController、代理服务。 | | Agent 运行时 | `crates/agent-gui/src/lib/chat`、`crates/agent-gui/src/pages/chat`、`crates/agent-gui/src/lib/tools` | TypeScript、`@mariozechner/pi-ai` | 构造上下文、请求模型、执行工具、压缩上下文、持久化历史、发布 Gateway 事件。 | -| Gateway | `crates/agent-gateway` | Go、gRPC、net/http、WebSocket | 桌面 Agent 与浏览器 WebUI 的远程中继、认证、会话管理、恢复缓冲、静态 WebUI 和分享页。 | +| Gateway | `crates/agent-gateway` | Go、net/http、WebSocket+Protobuf(v2;gRPC 已弃用) | 桌面 Agent 与浏览器 WebUI 的远程中继、认证、会话管理、恢复缓冲、静态 WebUI 和分享页。 | | WebUI | `crates/agent-gateway/web` | React、TypeScript、Vite、WebSocket | 远程浏览器端 Chat/Settings/Hub 壳层,通过 Gateway 操作本地 Agent。 | | 资料与策略 | `doc/`、`docs/` | Markdown、SQL | 历史设计、专项计划、当前架构索引。 | @@ -16,19 +16,19 @@ | 进程/运行环境 | 入口 | 和谁通信 | 权限边界 | |---|---|---|---| | Tauri WebView | `crates/agent-gui/src/main.tsx`、`src/App.tsx` | Tauri invoke、Gateway gRPC bridge、模型 API | 用户可见桌面界面,触发本地能力但不直接访问 Rust 内部状态。 | -| Tauri Rust 进程 | `src-tauri/src/main.rs`、`src-tauri/src/lib.rs` | 前端 invoke、SQLite、OS、Gateway gRPC、MCP server | 本地高权限真相源,负责系统能力、持久化与远程桥接。 | -| Gateway Go 进程 | `crates/agent-gateway/cmd/gateway/main.go` | Desktop gRPC、Browser WebSocket/HTTP | 网络中继层,不直接执行本地工具。 | -| Browser WebUI | `crates/agent-gateway/web/src/main.tsx`、`web/src/App.tsx` | Gateway `/ws`、`/api/*` | 远程 UI,仅持有 token、脱敏设置和本地浏览器缓存。 | +| Tauri Rust 进程 | `src-tauri/src/main.rs`、`src-tauri/src/lib.rs` | 前端 invoke、SQLite、OS、Gateway WebSocket v2(gRPC 回退)、MCP server | 本地高权限真相源,负责系统能力、持久化与远程桥接。 | +| Gateway Go 进程 | `crates/agent-gateway/cmd/gateway/main.go` | Desktop/Browser WebSocket v2(Protobuf 帧)、HTTP;v1 gRPC 与 JSON WS 弃用兼容 | 网络中继层,不直接执行本地工具。 | +| Browser WebUI | `crates/agent-gateway/web/src/main.tsx`、`web/src/App.tsx` | Gateway `/ws/v2`、`/api/*` | 远程 UI,仅持有 token、脱敏设置和本地浏览器缓存。 | ## 核心数据流 | 数据流 | 步骤 | 关键路径 | |---|---|---| | 本地桌面对话 | GUI composer 提交消息,`ChatPage` 构造上下文,按 execution mode 进入 text 或 agent turn,模型流式返回,必要时执行 builtin tools,最后写入历史 SQLite。 | `src/pages/ChatPage.tsx`、`src/pages/chat/runTextConversationTurn.ts`、`src/pages/chat/runAgentConversationTurn.ts`、`src/lib/providers/llm.ts`、`src/lib/tools/builtinRegistry.ts` | -| WebUI 远程对话 | WebUI optimistic echo 后先经 `/ws` 发 `chat.prepare`,Gateway 通过关联原生 Ping/Pong 验证 gRPC stream 并唤醒桌面 Chat Runtime;随后 `chat.command`(`chat.submit`/`chat.edit_resend`)accepted 并通过 `AgentConnect` 下发。桌面端本地运行并持续回传 `ChatEvent`/`ChatControlEvent`,Gateway 按 seq 经会话订阅(`chat.subscribe`/`chat.event`)推送给 WebUI。 | `web/src/lib/gatewaySocket.ts`、`internal/server/websocket_chat_handlers.go`、`internal/server/chat_commands.go`、`proto/v1/gateway.proto`、`src-tauri/src/services/gateway/*` | +| WebUI 远程对话 | WebUI optimistic echo 后先经 `/ws/v2` 发 `chat_prepare`,Gateway 通过关联原生 Ping/Pong 验证桌面端信封流并唤醒桌面 Chat Runtime;随后 `chat_command`(`chat.submit`/`chat.edit_resend`)accepted 并经 `/ws/v2/agent` 信封流下发。桌面端本地运行并持续回传 `ChatEvent`/`ChatControlEvent`,Gateway 按 seq 经会话订阅(`chat.subscribe`/`chat.event`)推送给 WebUI。 | `web/src/lib/gatewaySocket.ts`、`internal/protocol/pbws/browser_local.go`、`internal/chatcmd/chatcmd.go`、`proto/v1/gateway.proto`、`src-tauri/src/services/gateway/*` | | 设置同步 | GUI load/save 设置到本地 SQLite,同时发布脱敏 settings snapshot 到 Gateway;WebUI 读取/更新 settings 时走 Gateway,普通 sync 不带真实 provider API key。 | `src/lib/settings/*`、`src-tauri/src/commands/settings.rs`、`src/lib/settings/sync.ts`、`web/src/lib/settings/sync.ts` | | 历史同步 | GUI 持久化 `chatHistory` 和 `chatHistorySegment`,操作后发布 history sync;Gateway 转发给 WebUI,WebUI 刷新列表或详情缓存。 | `src-tauri/src/commands/chat_history.rs`、`src-tauri/src/services/gateway.rs`、`web/src/lib/historySync.ts` | -| 上传文件 | GUI 直接通过 Tauri 导入工作区 uploads;WebUI 走 Gateway HTTP multipart,Gateway 将 bytes 转成 gRPC request,桌面端导入本地工作区后返回文件引用。 | `src-tauri/src/commands/system.rs`、`internal/handler/upload.go`、`web/src/lib/uploadReadableFiles.ts` | +| 上传文件 | GUI 直接通过 Tauri 导入工作区 uploads;WebUI 走 Gateway HTTP multipart,Gateway 将 bytes 转成 `UploadReadableFilesRequest` 信封,桌面端导入本地工作区后返回文件引用。 | `src-tauri/src/commands/system.rs`、`internal/handler/upload.go`、`web/src/lib/uploadReadableFiles.ts` | | 记忆召回 | 每轮 Chat 可调用 Rust `MemoryStore` 生成 overview 注入 system prompt;工具层暴露 `MemoryManager` 读写;Settings Memory 展示和管理同一套 store。 | `src-tauri/src/services/memory.rs`、`src/lib/chat/memory/*`、`src/lib/tools/memoryTools.ts`、`src/pages/settings/MemoryPanel.tsx` | ## 当前主要持久化 @@ -64,8 +64,8 @@ Browser WebUI │ ▼ Go Gateway - ├─ HTTP: /ws, /api/status, /api/files/import, /api/public/history-shares/{token} - ├─ gRPC: AgentGateway.AgentConnect, Authenticate + ├─ HTTP/WS: /ws/v2, /ws/v2/agent, /ws/v2/terminal, /api/status, /api/files/import, /api/public/history-shares/{token} + ├─ 弃用兼容: /ws, /ws/terminal, gRPC AgentGateway.* └─ session.Manager: agent session, streams, settings/history subscribers, bounded chat relay window │ ▼ diff --git a/docs/architecture/protocol-v2-migration.md b/docs/architecture/protocol-v2-migration.md new file mode 100644 index 000000000..9663ffecb --- /dev/null +++ b/docs/architecture/protocol-v2-migration.md @@ -0,0 +1,69 @@ +# v2 协议迁移与 v1 移除计划 + +本文档记录 v1 → v2(WebSocket+Protobuf 统一线协议)迁移的现状、观察指标 +与最终删除 v1 的操作清单。协议合同见 [protocols.md](./protocols.md)。 + +## 迁移现状 + +| 组件 | 状态 | +|---|---| +| Gateway v2 服务端(`internal/protocol/pbws`,三链路) | ✅ 已上线,与 v1 并行服务 | +| 内嵌 WebUI | ✅ 已切换 v2(与网关同版本发布,lockstep) | +| 桌面端 Rust 客户端 | ✅ 新版走 v2,握手层失败自动回退 gRPC(兼容旧网关) | +| v1 弃用标记 | ✅ Go `// Deprecated:` / Rust `#[deprecated]` / TS `@deprecated` / proto `option deprecated` | +| v1 运行时打点 | ✅ `/api/status` → `protocol_usage`,v1 连接建立打 WARN 日志 | + +## 版本偏斜矩阵 + +| 客户端 | 网关 | 行为 | +|---|---|---| +| 旧浏览器标签页(v1 bundle) | 新网关 | 继续走 `/ws` v1,正常工作 | +| 新内嵌 WebUI | 新网关 | 走 `/ws/v2`(构建即 lockstep,不存在新 UI 配旧网关) | +| 新桌面端 | 旧网关(无 `/ws/v2/agent`) | 握手 404 → 同一次连接尝试内回退 gRPC;每次重连先试 v2 | +| 新桌面端 | 新网关 | 走 `/ws/v2/agent` | +| 旧桌面端 | 新网关 | 继续走 gRPC v1,正常工作 | + +鉴权被拒(`ServerHello{ok:false}`)不触发回退——那是配置错误,不是版本偏斜。 + +## 删除 v1 的前置条件 + +1. `/api/status` 的 `protocol_usage` 在一个完整观察窗(建议 ≥ 2 个桌面端 + 发版周期)内满足: + - `v1_ws_connections_total` / `v1_ws_requests_total` 不再增长; + - `v1_grpc_agent_connects_total` / `v1_grpc_terminal_connects_total` 不再增长; + - `v1_ws_connections_active` 与 `v1_grpc_agent_active` 持续为 0。 +2. 桌面端自动更新覆盖率达标(旧版桌面端不再活跃)。 +3. 网关日志中 `deprecated v1 ... established` WARN 不再出现。 + +## 删除清单(未来版本执行) + +Go 网关: + +- [ ] 删除 `internal/server/websocket*.go` 全部 v1 文件(envelope/路由表/ + 16 个 handler/payloads/terminal_stream/roundtrip 残留)及其测试 +- [ ] 删除 `internal/server/grpc.go` 与 `cmd/gateway` 中 gRPC 监听、TLS、 + keepalive、拦截器(`internal/auth` 的 gRPC 部分)装配 +- [ ] 删除 `internal/chatwire` 中仅 v1 需要的 JSON 塑形(注意:入口塑形被 + v2 `payload_json` 复用,需先甄别) +- [ ] `http.go` 移除 `/ws`、`/ws/terminal` 路由与 `?terminal=1` 分支 +- [ ] `proto/v1/gateway.proto` 删除 `service AgentGateway`(**消息全部保留**, + 它们是 v2 的载荷);`buf generate` +- [ ] `go.mod` 移除 `google.golang.org/grpc`(确认无其他引用) +- [ ] `--grpc-addr` 等 gRPC 配置项先转弃用 no-op 一个版本,再删除 +- [ ] 移除 `.golangci.yml` 中 v1 路径的 SA1019 豁免 +- [ ] 移除 `observability/protousage.go` 中 v1 计数器 + +桌面端 Rust: + +- [ ] 删除 `connect_and_serve_grpc`、`build_grpc_url`、`build_endpoint`、 + `insert_bearer_metadata` 与 gRPC 终端流及回退调度分支 +- [ ] `Cargo.toml` 移除 `tonic`/`tonic-prost` 的 transport 依赖 + (prost 生成仍需要 `tonic-prost-build` 或改为纯 `prost-build`) + +WebUI: + +- [ ] 删除标记为 `@deprecated` 的 v1 线格式残留类型/工具 + +文档: + +- [ ] `protocols.md` 删除 v1 附录;本文件归档 diff --git a/docs/architecture/protocols.md b/docs/architecture/protocols.md index f156d7152..2eaea0860 100644 --- a/docs/architecture/protocols.md +++ b/docs/architecture/protocols.md @@ -2,53 +2,98 @@ ## 协议总览 +自 v2 起,网关的全部实时链路统一为 **WebSocket + Protobuf**(下称 v2 协议)。 +v1 双协议(浏览器 JSON WebSocket + 桌面端 gRPC)**已弃用**,仅为旧客户端保留, +流量归零后将整体删除(见 [protocol-v2-migration.md](./protocol-v2-migration.md))。 + | 通道 | 端点 | 方向 | 用途 | |---|---|---|---| -| gRPC unary | `AgentGateway.Authenticate` | Desktop -> Gateway | 桌面端认证与 session 初始化。 | -| gRPC stream | `AgentGateway.AgentConnect` | Desktop <-> Gateway | 桌面端常驻双向通道,承载 GatewayEnvelope 与 AgentEnvelope。 | -| gRPC stream | `AgentGateway.AgentTerminalConnect` | Desktop <-> Gateway | 终端专用字节流,承载 `TerminalStreamFrame`,避免 terminal IO 与 chat/settings/history 队头阻塞。 | -| WebSocket | `GET /ws` | WebUI <-> Gateway | WebUI 非 Chat 请求/响应、状态广播、history/settings 等同步。 | -| WebSocket | `GET /ws/terminal` | WebUI <-> Gateway | WebUI 终端专用二进制流;JSON 控制帧只用于 auth/error,热路径为 bytes frame。 | -| WS Chat Prepare | WebSocket `chat.prepare` | WebUI -> Gateway <-> Desktop | 提交前发送带关联 request id 的原生 Ping/Pong,验证当前 gRPC stream 可往返并唤醒桌面 Chat Runtime。 | -| WS Chat Command | WebSocket `chat.command` | WebUI -> Gateway -> Desktop | Chat 提交、编辑重发、取消;命令先被 Gateway accepted,再异步下发桌面端;pre-stream 结果经 `chat.command_update` 推送。 | -| WS Chat Stream | WebSocket `chat.subscribe` / `chat.event` | Gateway -> WebUI | 按 conversation 持久订阅(跨 run);订阅响应按 `after_seq`/`stream_epoch` 补发缺失事件,之后 `chat.event` 实时推送,`chat.unsubscribe` 结束。 | -| HTTP API | `/api/status` | WebUI -> Gateway | 查询 Agent 在线状态。 | +| **v2** WebSocket | `GET /ws/v2` | WebUI <-> Gateway | 浏览器主链路:本地操作 + `GatewayEnvelope` 直通请求 + 广播事件。 | +| **v2** WebSocket | `GET /ws/v2/agent` | Desktop <-> Gateway | 桌面端常驻双向信封流(取代 gRPC `Authenticate` + `AgentConnect`)。 | +| **v2** WebSocket | `GET /ws/v2/terminal` | 两端 <-> Gateway | 终端专用数据面(角色由 hello 区分),承载 `TerminalStreamFrame`,避免终端 IO 与 chat/settings/history 队头阻塞。 | +| HTTP API | `/api/status` | WebUI -> Gateway | Agent 在线状态 + `protocol_usage`(v1/v2 使用计数)。 | | HTTP upload | `/api/files/import` | WebUI -> Gateway -> Desktop | 上传可读文件并导入桌面 workspace。 | | Public HTTP | `/api/public/history-shares/{token}` | Browser -> Gateway | 公开只读历史分享。 | - -## gRPC Envelope - -`crates/agent-gateway/proto/v1/gateway.proto` 定义两个主 envelope: - -| Envelope | 方向 | payload 示例 | -|---|---|---| -| `GatewayEnvelope` | Gateway -> Desktop | `PingRequest`、`ChatCommandRequest`、`CronManageRequest`、`History*Request`、`ProviderListRequest`、`Settings*Request`、`Skill*Request`、`FileMentionListRequest`、`UploadReadableFilesRequest`、`MemoryManageRequest`。 | -| `AgentEnvelope` | Desktop -> Gateway | `PongResponse`、`ChatEvent`、`ChatControlEvent`、`CronManageResponse`、`History*Response`、`HistorySyncEvent`、`ProviderListResponse`、`Settings*Response`、`SettingsSyncEvent`、`Skill*Response`、`UploadReadableFilesResponse`、`MemoryManageResponse`、`ErrorResponse`。 | +| ~~v1 WebSocket~~ | `GET /ws`、`GET /ws/terminal` | WebUI <-> Gateway | **已弃用**:JSON 信封协议与自定义二进制终端流(见附录)。 | +| ~~v1 gRPC~~ | `AgentGateway.*` | Desktop <-> Gateway | **已弃用**:`Authenticate`/`AgentConnect`/`AgentTerminalConnect`(见附录)。 | + +## v2 统一线协议 + +权威定义:`crates/agent-gateway/proto/v2/gateway_ws.proto`(帧壳),业务消息 +全部复用 `proto/v1/gateway.proto`(`GatewayEnvelope`/`AgentEnvelope`/ +`TerminalStreamFrame` 等——单一事实源,三端 Go/Rust/TS 均由它生成)。 + +### 传输与握手 + +- WebSocket 子协议:`liveagent.v2.pb`(服务端必回显)。 +- 一条 WS 二进制消息 = 一条 proto 帧消息,无长度前缀;v2 路径上文本帧被忽略。 +- 首帧必须为 `ClientHello{protocol_version=2, role, token, ...}`;服务端应答 + `ServerHello{ok, session_id, heartbeat_period_seconds, max_message_bytes}`; + 鉴权失败以 close code 4401 关闭。agent 角色的 hello 同时完成 v1 + `Authenticate` 的会话登记职能。 +- 消息大小上限沿用 `GRPCMaxMessageBytes`(默认 64 MiB),经 `ServerHello` 通告。 + +### 浏览器链路(/ws/v2) + +- 请求帧 `WebClientFrame{request_id, oneof payload}`;响应帧回显同一 + `request_id`;广播帧 `request_id` 为空。 +- **直通请求** `agent_request`(浏览器直接构造 v1 `GatewayEnvelope` 载荷臂): + 网关按白名单与限额校验(`internal/protocol/pbws/guard.go`,承接 v1 字符串 + 路由表的安全职能)、把 `request_id` 按连接命名空间化后近乎原样转发桌面端, + 响应以原始 `AgentEnvelope`(`agent_response` 臂)回送。v1 时代约 90 个 + “JSON 解码 → 手工组 proto → 手工拆 map” 处理器由这一条路径取代。 +- **本地帧**(网关状态直接应答/编排):`status_get`、`chat_prepare`、 + `chat_command`(携带 v1 `ChatCommandRequest`)、`chat_subscribe`/ + `chat_unsubscribe`/`chat_activities`、`workspace_subscribe`/`workspace_unsubscribe`。 +- **广播臂**:`history_event`/`settings_event`/`terminal_event`/`sftp_event`/ + `chat_queue_event`/`tunnel_state`/`process_state`/`workspace_activity` + 直转 session 层的 v1 seam 消息;`status`/`chat_activity`/`chat_event`/ + `chat_command_update`/`chat_subscription_reset` 为 v2 新增 proto 化载荷。 + chat 事件载荷保持动态 JSON(`payload_json` bytes),与 v1 同源同形。 +- **本地错误**:`local_error`(`ErrorResponse`),对应 v1 的 `type:"error"` 信封。 +- 心跳与背压:服务端 WS 控制帧 ping + 应用层 `PingFrame` 双通道;空闲驱逐 + `3×心跳周期+宽限`;写侧为控制优先双队列 + 可掉帧数据 + 关联响应掉帧即 + 断连(与 v1 完全同一套 `internal/transport/wscore` 运行时)。 + +### 桌面端链路(/ws/v2/agent) + +hello(role=AGENT)完成鉴权与会话登记后,双向信封流语义与 v1 gRPC +`AgentConnect` 完全一致:网关下行 `GatewayEnvelope`(请求 + 周期 Ping), +桌面端上行 `AgentEnvelope`(响应/事件/Pong);心跳走独立通道不受数据 +拥塞影响;传输层保活由 WS 控制帧 ping/pong 承担(取代 h2 keepalive), +客户端以 3×心跳周期无入站为断链判据。 + +### 终端链路(/ws/v2/terminal) + +两端共用一条路径,hello.role 区分浏览器/桌面端;hello 之后双向承载 +`TerminalStreamFrame`(proto 直传,淘汰 v1 浏览器侧的 +`[ver][kind][len][JSON 头][bytes]` 手工帧)。浏览器侧语义不变:attach/detach +维护本连接订阅集,input/resize 需已附着,output 只投递给已附着连接; +桌面端侧对应 v1 `AgentTerminalConnect`(就绪信号由 `ServerHello` 承担, +不再发送合成 ready 帧)。 ## Chat 协议 | 阶段 | WebUI -> Gateway | Gateway -> Desktop | Desktop -> Gateway -> WebUI | |---|---|---|---| -| 唤醒 | WS `chat.prepare` | `PingRequest{request_id=chat-runtime-wake-*}` | Rust emit WebView wake,可靠返回关联 `PongResponse`;Gateway 完成真实原生往返后响应当前 status。 | -| 提交 | WS `chat.command`,`type=chat.submit` | `ChatCommandRequest{type=chat.submit}` | 响应携带 `run_id`/`accepted_seq`;用户消息与 token 事件经会话订阅 `chat.event` 推送。 | -| 编辑重发 | WS `chat.command`,`type=chat.edit_resend` | `ChatCommandRequest{type=chat.edit_resend, base_message_ref}` | Gateway 先发布 `rebased` 与新用户消息事件,桌面端随后原子截断并运行新 turn。 | -| 恢复 | WS `chat.subscribe`,`{conversation_id, after_seq, stream_epoch}` | 无 | WebUI 先用 history snapshot/projection hydrate,订阅响应由 Gateway 进程内事件窗口按 conversation seq 跨 run 补发缺失事件(`events`/`latest_seq`/`reset`);epoch 改变或窗口不足时返回 reset。订阅缓冲溢出时 Gateway 发 `chat.subscription_reset`,客户端按游标重新订阅。 | -| 取消 | WS `chat.command`,`type=chat.cancel` | `ChatCommandRequest{type=chat.cancel}` | Gateway 置 `cancelling` 状态,桌面端真实终态优先,超时由 watchdog 兜底 `run_finished(cancelled)`。 | +| 唤醒 | `chat_prepare` | `PingRequest{request_id=chat-runtime-wake-*}` | Rust emit WebView wake,可靠返回关联 `PongResponse`;Gateway 完成真实原生往返后响应当前 status。 | +| 提交 | `chat_command`,`type=chat.submit` | `ChatCommandRequest{type=chat.submit}` | `chat_accepted` 携带 `run_id`/`accepted_seq`;用户消息与 token 事件经会话订阅 `chat_event` 推送。 | +| 编辑重发 | `chat_command`,`type=chat.edit_resend` | `ChatCommandRequest{type=chat.edit_resend, base_message_ref}` | Gateway 先发布 `rebased` 与新用户消息事件,桌面端随后原子截断并运行新 turn。 | +| 恢复 | `chat_subscribe`,`{conversation_id, after_seq, stream_epoch}` | 无 | WebUI 先用 history snapshot/projection hydrate,订阅响应由 Gateway 进程内事件窗口按 conversation seq 跨 run 补发缺失事件(`events_json`/`latest_seq`/`reset`);epoch 改变或窗口不足时返回 reset。订阅缓冲溢出时 Gateway 发 `chat_subscription_reset`,客户端按游标重新订阅。 | +| 取消 | `chat_command`,`type=chat.cancel` | `ChatCommandRequest{type=chat.cancel}` | Gateway 置 `cancelling` 状态,桌面端真实终态优先,超时由 watchdog 兜底 `run_finished(cancelled)`。 | | 完成 | 无 | 无 | `ChatEvent.type=DONE` 映射为 `run.completed` 终态。 | -桌面端仍通过 `ChatEvent` 表达 `TOKEN`、`THINKING`、`TOOL_CALL`、`TOOL_RESULT`、`DONE`、`ERROR`、`TOOL_STATUS`、`HOSTED_SEARCH` 等低层事件。Gateway 对外统一附加同 conversation 内单调递增的 `seq`,并把控制事件规范化为 `run.accepted`、`user.message.appended`、`conversation.rebased`、`projection.updated`、`run.completed`、`run.failed`、`run.cancelled` 等 WebUI 事件。旧 HTTP SSE Chat 路由(`GET /api/chat/events`)已下线。 - -Chat command(WS `chat.command`)只接受 `{ type, payload }` envelope;`command`、顶层裸 payload、`request_id` 别名都不再作为兼容输入。 +桌面端仍通过 `ChatEvent` 表达 `TOKEN`、`THINKING`、`TOOL_CALL`、`TOOL_RESULT`、`DONE`、`ERROR`、`TOOL_STATUS`、`HOSTED_SEARCH` 等低层事件。Gateway 对外统一附加同 conversation 内单调递增的 `seq`,并把控制事件规范化为 `run.accepted`、`user.message.appended`、`conversation.rebased`、`projection.updated`、`run.completed`、`run.failed`、`run.cancelled` 等 WebUI 事件。命令编排逻辑(去重、探活、接受回执、启动看门狗)收敛于 `internal/chatcmd`,v1/v2 共用。 -WebUI 对 command ACK 使用 4 秒上限。连接中断或 ACK 丢失时仅重试一次,并复用完全相同的 payload 与 `client_request_id`;Gateway 在同一进程内原子返回 canonical run,因此不会重复 seed 或 dispatch。成功 prepare 的探测新鲜度绑定 Agent session epoch 并保留 2 秒,紧随 command 可直接复用,避免正常路径重复原生 RTT;旧客户端或过期结果仍在 accepted 前现场 probe。accepted ACK 与 `chat.prepare` response 走 WebSocket 控制优先队列,避免被 token 数据帧队头阻塞。 +WebUI 对 command ACK 使用 4 秒上限。连接中断或 ACK 丢失时仅重试一次,并复用完全相同的 payload 与 `client_request_id`;Gateway 在同一进程内原子返回 canonical run,因此不会重复 seed 或 dispatch。成功 prepare 的探测新鲜度绑定 Agent session epoch 并保留 2 秒,紧随 command 可直接复用,避免正常路径重复原生 RTT;`chat_accepted` 与 `chat_prepare` 响应走 WebSocket 控制优先队列,避免被 token 数据帧队头阻塞。 ## Settings 同步 | 操作 | 方向 | 语义 | |---|---|---| -| `settings.get` | WebUI -> Gateway -> Desktop | 读取桌面端当前 settings snapshot。 | -| `settings.update` | WebUI -> Gateway -> Desktop | 更新设置;provider secret 使用单独 `providerApiKeyUpdates`。 | -| `settings.event` / `SettingsSyncEvent` | Desktop -> Gateway -> WebUI | GUI 本地保存后广播脱敏 settings snapshot。 | +| `SettingsGetRequest`(直通) | WebUI -> Gateway -> Desktop | 读取桌面端当前 settings snapshot。 | +| `SettingsUpdateRequest`(直通) | WebUI -> Gateway -> Desktop | 更新设置;provider secret 使用单独 `providerApiKeyUpdates`。 | +| `settings_event` / `SettingsSyncEvent` | Desktop -> Gateway -> WebUI | GUI 本地保存后广播脱敏 settings snapshot(`settings_json` 由客户端解析)。 | 设置协议的关键约束是 provider API key 不走普通 sync snapshot。WebUI 只能看到 redacted provider 数据和 `apiKeyConfigured` 状态。 @@ -56,12 +101,12 @@ WebUI 对 command ACK 使用 4 秒上限。连接中断或 ACK 丢失时仅重 | 操作 | 语义 | |---|---| -| `history.list` | 分页读取 conversation summary,用于 sidebar;`running_conversations` 会附带 `run_id`、`first_seq`、`latest_seq`、`run_epoch`,让 WebUI 观察远程运行时从当前 run 起点发起 `chat.subscribe`。 | -| `history.get` | 读取 conversation detail;支持 `max_messages` 返回 tail window。 | -| `history.rename` | 修改标题并广播 upsert event。 | -| `history.pin` | 修改置顶状态并保持排序。 | -| `history.share.get/set` | 管理公开分享 token 与 redaction 选项。 | -| `history.delete` | 删除会话和相关 FTS/share 行。 | +| `HistoryListRequest` | 分页读取 conversation summary,用于 sidebar;网关钳制分页(page 默认 1、page_size 默认 80 上限 200)。 | +| `HistoryGetRequest` | 读取 conversation detail;支持 `max_messages` 返回 tail window。 | +| `HistoryRenameRequest` | 修改标题并广播 upsert event。 | +| `HistoryPinRequest` | 修改置顶状态并保持排序。 | +| `HistoryShareGet/SetRequest` | 管理公开分享 token 与 redaction 选项。 | +| `HistoryDeleteRequest` | 删除会话和相关 FTS/share 行。 | | 编辑重发截断 | 不再暴露独立 WebUI history 命令;由 `chat.edit_resend` 在桌面端处理,并通过 `conversation.rebased`/`projection.updated` 同步视图。 | 桌面端是历史数据库真相源;Gateway 负责 request forwarding 和 sync event broadcasting;WebUI 负责本地列表和 transcript 状态更新。 @@ -75,7 +120,8 @@ WebUI 对 command ACK 使用 4 秒上限。连接中断或 ACK 丢失时仅重 | 3 | Desktop 根据 workdir 导入 `.liveagent`/uploads 类工作区位置,返回 `ChatUploadedFile` 列表和 skipped 列表。 | | 4 | WebUI 把返回的 uploaded files 附加到下一次 Chat Command。 | -GUI 本地上传不需要 HTTP/Gateway,直接通过 Tauri command 导入。 +GUI 本地上传不需要 HTTP/Gateway,直接通过 Tauri command 导入。上传臂不在 +`agent_request` 直通白名单内(大文件走 HTTP multipart 更合适)。 ## Public Share 错误码 @@ -91,20 +137,22 @@ Gateway 不再通过错误文案推断 public share 状态,错误语义由桌 ## Terminal Stream 协议 -终端已从普通 WebSocket RPC 模型切换为独立 stream 模型。普通 `/ws` 只保留 session list/create/close/rename、SSH prompt、SSH tabs 等控制面与 metadata 同步;高频 `attach/input/resize/output/detach` 不再作为 `/ws` request/response 存在。 +终端为独立 stream 模型。主链路(`/ws/v2` 直通 `TerminalRequest`)只承载 +session list/create/close/rename、SSH prompt、SSH tabs 等控制面与 metadata +同步;高频 `attach/input/resize/output/detach` 走 `/ws/v2/terminal` 数据面。 | 层级 | 合同 | |---|---| -| Browser-Gateway | `GET /ws/terminal` 首帧发送 `{type:"auth", token}`;之后使用二进制 frame:`version(1) + kind(1) + headerLength(2, big endian) + JSON header + bytes`。 | -| Frame header | `kind` 为 `attach/input/resize/detach/output/snapshot/error`;header 包含 `streamId/sessionId/projectPathKey/seq/startOffset/endOffset/cols/rows/maxBytes/truncated/error`。 | -| Desktop-Gateway | `AgentTerminalConnect(stream TerminalStreamFrame) returns (stream TerminalStreamFrame)`;`AgentConnect` 不承载 terminal output/input/resize。 | -| Snapshot | attach 返回 `snapshot` frame,data 为 tail bytes,header 的 `startOffset/endOffset` 用于前端去重。 | +| Browser-Gateway | `GET /ws/v2/terminal` 首帧 `ClientHello{role=BROWSER}`;之后 `TerminalClientFrame{frame}` / `TerminalServerFrame{frame}` 双向承载 proto `TerminalStreamFrame`。 | +| Frame 字段 | `kind` 为 `attach/input/resize/detach/output/snapshot/error`;含 `stream_id/session_id/project_path_key/seq/start_offset/end_offset/cols/rows/max_bytes/truncated/error/data`。 | +| Desktop-Gateway | `GET /ws/v2/terminal` 首帧 `ClientHello{role=AGENT}`,其后帧语义与 v1 `AgentTerminalConnect` 一致;主链路不承载 terminal output/input/resize。 | +| Snapshot | attach 返回 `snapshot` frame,data 为 tail bytes,`start_offset/end_offset` 用于前端去重。 | | Input | input frame 为 fire-and-forget bytes;不返回 session metadata,不进入普通 request pending map。 | | Resize | resize frame 只发送最新 cols/rows;不返回 session metadata。 | | Output | output frame 只携带轻量 session id、project key、offset 与 bytes;React session state 不因 output 更新。 | -| 页面 stream client | `BrowserGatewayTerminalStreamClient` 每页按 token 维护一条 terminal stream,上游按 session 复用 attach;同 session 的多个 handle 共享 output。 | +| 页面 stream client | 每页按 token 维护一条 terminal stream,上游按 session 复用 attach;同 session 的多个 handle 共享 output。 | -Gateway 的 `/ws/terminal` 连接只维护本连接内的 session attach 集合;detach 只影响这条 terminal stream 的输出投递,不改变桌面端 terminal registry。 +Gateway 的终端连接只维护本连接内的 session attach 集合;detach 只影响这条 terminal stream 的输出投递,不改变桌面端 terminal registry。 ## Workspace Activity 协议 @@ -114,17 +162,17 @@ Git 面板与文件树不再轮询:桌面端 `workspace_watch` 服务(notify |---|---| | Desktop 内 | Tauri 事件 `workspace:activity`,payload `{workdir, revision, fs, git, changedPaths, truncated}`;前端经 `workspace_watch_set(workdirs)` 声明式注册本 webview 的观察集合。 | | Desktop→Gateway | `AgentEnvelope.workspace_activity`(`WorkspaceActivityEvent`,字段 90)。Gateway→Desktop 用 `GatewayEnvelope.workspace_watch`(`WorkspaceWatchRequest`,声明式全量 workdir 集合;订阅计数变化与 agent 重连时重发)。 | -| Browser-Gateway | `/ws` 方法 `workspace.subscribe/unsubscribe {workdir}`,事件 `workspace.activity`。 | +| Browser-Gateway | `/ws/v2` 帧 `workspace_subscribe/workspace_unsubscribe {workdir}`,事件臂 `workspace_activity`。 | | 语义 | best-effort 失效信号,不保证不丢事件:客户端在(重)订阅、通道重建、revision 回退时必须自标脏并 refetch。revision 为 per-workdir 单调计数(agent 进程内)。 | | 消费端 | 两端镜像的 `lib/workspace-activity/useWorkspaceInvalidation`:面板隐藏时只置脏、激活时冲刷;数据本体仍走既有 fs/git 拉取命令(invalidate-push + fetch-on-demand)。 | ## Skills 与 Memory 管理协议 -| 能力 | WebUI 方法 | Desktop 落点 | +| 能力 | 直通请求臂 | Desktop 落点 | |---|---|---| -| Skills 列表和管理 | `skills.list`、`skills.manage`、`skills.read-metadata`、`skills.read-text` | `system_ensure_builtin_skills`、`system_manage_skill`、`system_read_skill_*`、`services/skills.rs` | -| Memory 管理 | `memory.manage` | `commands/memory.rs`、`services/memory.rs` | -| Cron 管理 | `cron.manage` | `commands/cron.rs`、`services/cron.rs`、settings cron 表 | +| Skills 列表和管理 | `SkillFilesListRequest`、`SkillManageRequest`、`SkillMetadataReadRequest`、`SkillTextReadRequest` | `system_ensure_builtin_skills`、`system_manage_skill`、`system_read_skill_*`、`services/skills.rs` | +| Memory 管理 | `MemoryManageRequest` | `commands/memory.rs`、`services/memory.rs` | +| Cron 管理 | `CronManageRequest` | `commands/cron.rs`、`services/cron.rs`、settings cron 表 | ## 恢复与去重机制 @@ -132,7 +180,8 @@ Git 面板与文件树不再轮询:桌面端 `workspace_watch` 服务(notify |---|---|---| | `clientRequestId` | WebUI Chat Command -> Gateway session manager | 进程级 24 小时幂等键;并发或单次 ACK 恢复重试返回同一 canonical run。Gateway 重启后不保留。 | | `conversationId` -> run index | Gateway session manager | 当前会话刷新/切换后可定位正在运行的事件流。 | -| `Seq` | Gateway 进程内 conversation event window / `chat.event` payload | 同 conversation 内单调递增;断线后 `chat.subscribe` 携带 `after_seq` 游标补发窗口内缺失事件,窗口不足时 reset + history hydrate。 | +| `Seq` | Gateway 进程内 conversation event window / `chat_event` payload | 同 conversation 内单调递增;断线后 `chat_subscribe` 携带 `after_seq` 游标补发窗口内缺失事件,窗口不足时 reset + history hydrate。 | +| 直通关联 id 命名空间 | Gateway v2 relay | 多标签页共享一个桌面端;网关按连接为 `request_id` 加前缀转发、回程剥离,杜绝跨连接冲突。 | | done retention | Gateway session manager | 已结束 run 短时间保留,支持刷新后看到终态。 | | local running ids | WebUI App | 避免正在运行会话被错误切换或误删。 | @@ -140,8 +189,27 @@ Git 面板与文件树不再轮询:桌面端 `workspace_watch` 服务(notify | 场景 | 必查点 | |---|---| -| 新增 Gateway request | 同步 `proto/v1/gateway.proto`、Go server、Tauri gateway bridge、WebUI client method。 | +| 新增 Gateway request | 在 `proto/v1/gateway.proto` 加请求/响应臂(编号只增不改)→ `buf generate` → v2 直通白名单(`internal/protocol/pbws/guard.go`)放行 → WebUI client method + adapter;桌面端 `envelope_handler.rs` 增加分支。不再需要 Go 手工 payload 塑形。 | +| 新增本地/编排操作 | `proto/v2/gateway_ws.proto` 加帧臂 → pbws 本地处理器 → 客户端方法。 | +| proto 演进纪律 | CI `buf breaking`(WIRE_JSON)把关;删除字段用 `reserved`;v2 复用的 v1 消息永不改号、永不弃用。 | | 新增 settings 字段 | GUI settings normalize/storage、Rust settings save/load、Gateway redaction whitelist、WebUI settings copy 都要同步。 | -| 新增 history 字段 | Rust summary model、proto `ConversationSummary`、Gateway websocket payload、GUI/WebUI sidebar render 都要同步。 | -| 新增 chat event | Desktop event publisher、proto enum、Gateway 事件规范化与 `chat.event` payload、WebUI event reducer/transcript 都要同步。 | +| 新增 history 字段 | Rust summary model、proto `ConversationSummary`、GUI/WebUI sidebar render 都要同步。 | +| 新增 chat event | Desktop event publisher、proto enum、Gateway 事件规范化与 `chat_event` payload、WebUI event reducer/transcript 都要同步。 | | 涉及 secret | 默认不进普通 sync,必须设计单向或显式更新通道。 | + +## 附录:v1 协议(已弃用) + +以下合同仅为旧客户端保留(未刷新的浏览器标签页、旧版桌面应用),网关侧 +实现全部带 `Deprecated` 标记并计数(`/api/status` 的 `protocol_usage`); +删除计划见 [protocol-v2-migration.md](./protocol-v2-migration.md)。 + +- **浏览器 JSON WebSocket**(`GET /ws`):文本帧 JSON 信封 + `{id, type, payload, error}`;首帧 `type:"auth"`;约 95 个 `type` 字符串 + 路由(`internal/server/websocket_routes.go`);响应 `type:"response"`、 + 错误 `type:"error"`、广播 `.event`、心跳 `ping`/`pong`。 +- **浏览器终端流**(`GET /ws/terminal`,或 `/ws?terminal=1`):首帧 JSON + auth,之后 `version(1)+kind(1)+headerLength(2,BE)+JSON header+bytes` + 自定义二进制帧。 +- **桌面端 gRPC**(`AgentGateway` 服务):`Authenticate` unary + + `AgentConnect`/`AgentTerminalConnect` 双向流;业务信封与 v2 完全同构 + (同一份 proto 消息)。 diff --git a/docs/architecture/webui.md b/docs/architecture/webui.md index 41490de39..3fcb088ad 100644 --- a/docs/architecture/webui.md +++ b/docs/architecture/webui.md @@ -9,7 +9,7 @@ WebUI 是 Gateway 承载的浏览器端操作台。它复用/复制了大量 GUI | 模块 | 路径 | 职责 | |---|---|---| | App shell | `crates/agent-gateway/web/src/App.tsx` | 登录、socket 生命周期、settings/history/chat 状态、页面切换、composer/transcript。 | -| Socket client | `web/src/lib/gatewaySocket.ts` | WebSocket 请求/响应、广播监听、连接超时、原生 Chat Runtime 唤醒、Chat command ACK 恢复与错误处理。 | +| Socket client | `web/src/lib/gatewaySocket.ts` | v2 WebSocket(Protobuf 帧)请求/响应、广播监听、连接超时、原生 Chat Runtime 唤醒、Chat command ACK 恢复与错误处理;proto 生成代码位于 `web/src/lib/proto/gen/`。 | | Conversation stream | `web/src/lib/chat/stream/conversationStreamClient.ts` | 按会话持久订阅注册表:维护 `after_seq`/`stream_epoch` 游标、重连自动重订阅、gap resync 与有界退避重试。 | | Gateway types | `web/src/lib/gatewayTypes.ts` | WebUI 侧协议类型。 | | Settings storage | `web/src/lib/webSettings.ts`、`web/src/lib/settings/*` | 浏览器本地设置缓存、脱敏 provider snapshot、settings sync payload。 | @@ -23,7 +23,7 @@ WebUI 是 Gateway 承载的浏览器端操作台。它复用/复制了大量 GUI | 阶段 | 行为 | |---|---| | token 读取 | WebUI 从浏览器存储读取 token,或通过 LoginPage 输入。 | -| socket 创建 | `getGatewayWebSocketClient(token)` 建立 `/ws` 连接;连接建立总超时为 10 秒,认证另有 15 秒超时,旧连接迟到的 close 不会误伤新连接。 | +| socket 创建 | `getGatewayWebSocketClient(token)` 建立 `/ws/v2` 连接(Protobuf 二进制帧,子协议 `liveagent.v2.pb`);连接建立总超时为 10 秒,认证另有 15 秒超时,旧连接迟到的 close 不会误伤新连接。 | | 状态订阅 | 订阅 Gateway status,展示 Desktop Agent online/offline。 | | 请求响应 | 所有 request 带 id,Gateway 用同 id 返回 payload 或 error。 | | Chat 唤醒 | 用户消息先即时 optimistic echo,再串行发送 `chat.prepare`;Gateway 通过关联原生 Ping/Pong 真正唤醒桌面 Chat Runtime,并让紧随其后的 command 复用同一 Agent session 上 2 秒内的新鲜探测,避免正常路径重复一个原生 RTT。准备请求最多等待 2.5 秒,旧 Gateway 不支持该方法时回退到 `status.get`,最终仍由 `chat.command` 作为兜底唤醒信号。 | diff --git a/docs/operations/development.md b/docs/operations/development.md index 0eb76501a..4741fd751 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -34,11 +34,15 @@ | GUI build | `pnpm -C crates/agent-gui build` | | WebUI build | `pnpm -C crates/agent-gateway/web build` | | Gateway tests | `cd crates/agent-gateway && go test ./...` | +| Gateway lint | `cd crates/agent-gateway && golangci-lint run ./...` | +| Proto 检查 | `make proto-check`(buf lint + 对 origin/main 的 breaking 检查) | | Tauri/Rust tests | `cargo test --manifest-path crates/agent-gui/src-tauri/Cargo.toml` | | 前端专项测试 | `pnpm -C crates/agent-gui test:frontend` | | diff 空白检查 | `git diff --check` | | 当前改动 | `git status --short` | +工具链版本由根 `mise.toml` 固定(git 跟踪),`mise install` 一键对齐,CI 使用相同版本。 + 实际脚本名称可能随 package.json 调整,运行前以当前 manifest 为准。 ## 运行时路径 @@ -58,9 +62,23 @@ |---|---| | HTTP | `internal/server/http.go` 注册 `/ws`、`/api/status`、`/api/files/import`、public share 和静态资源。 | | gRPC | `cmd/gateway/main.go` 创建 `AgentGateway` gRPC server,并启用 token interceptor。 | -| Proto | 修改 `proto/v1/gateway.proto` 后需要重新生成 Go 文件。 | +| Proto | 改 `proto/v1|v2/*.proto` 后执行 `make proto`(buf 生成 Go+TS),生成物随源同 PR 提交;`make proto-check` 把关破坏性变更。 | | Shutdown | `make dev-gateway` 应支持 Ctrl+C 后 HTTP 与 gRPC 干净退出。 | | WebUI embed | Gateway build 通常依赖 `make webui` 先产出静态资源。 | +| 新增桌面端能力 | v1 envelope 加臂(编号只增不改)→ `make proto` → v2 直通白名单(`internal/protocol/pbws/guard.go`)放行 → 各端生成物随源同 PR 提交;新增网关本地操作则在 v2 帧(`proto/v2/gateway_ws.proto`)加臂。 | +| v1 弃用惯例 | Go `// Deprecated: <原因;替代物;删除条件>`、Rust `#[deprecated]`、TS `@deprecated`、proto `option deprecated`;弃用代码原地保留只修 bug;删除条件看 `/api/status` 的 `protocol_usage` v1 计数归零(清单见 [protocol-v2-migration.md](../architecture/protocol-v2-migration.md))。 | + +## Gateway 分层(新代码放哪里) + +| 代码类型 | 位置 | +|---|---| +| 传输机制(写泵/背压/心跳,帧格式无关) | `internal/transport/wscore` | +| v2 协议编解码/握手/直通/扇出 | `internal/protocol/pbws` | +| 跨协议域逻辑(终端门控、Origin 校验等) | `internal/protocol/shared` | +| chat 命令编排 | `internal/chatcmd` | +| 会话状态与关联路由(transport 无关) | `internal/session` | +| 日志装置与协议使用打点 | `internal/observability` | +| v1 遗留实现(弃用,只修 bug 不加功能) | `internal/server` | ## GUI/WebUI 双端改造检查 diff --git a/docs/reference/source-map.md b/docs/reference/source-map.md index 5f8ba9709..1e72aa57e 100644 --- a/docs/reference/source-map.md +++ b/docs/reference/source-map.md @@ -78,14 +78,19 @@ | Gateway entry | `crates/agent-gateway/cmd/gateway/main.go` | | Shutdown helper | `crates/agent-gateway/cmd/gateway/shutdown.go` | | Config | `crates/agent-gateway/internal/config/config.go` | -| gRPC server | `crates/agent-gateway/internal/server/grpc.go` | +| v2 协议层(WebSocket+Protobuf) | `crates/agent-gateway/internal/protocol/pbws/*`(browser/agent/terminal 三链路、guard 白名单、seam 映射) | +| WS 连接运行时(v1/v2 共用) | `crates/agent-gateway/internal/transport/wscore/*` | +| 协议共用域逻辑 | `crates/agent-gateway/internal/protocol/shared/*`(Origin 校验、终端门控/后处理、终端兴趣跟踪) | +| Chat 命令编排(v1/v2 共用) | `crates/agent-gateway/internal/chatcmd/chatcmd.go` | +| 可观测性 | `crates/agent-gateway/internal/observability/*`(slog 初始化、v1/v2 使用计数) | +| gRPC server(弃用) | `crates/agent-gateway/internal/server/grpc.go` | | HTTP routes | `crates/agent-gateway/internal/server/http.go` | -| WebSocket server | `crates/agent-gateway/internal/server/websocket.go`、`websocket_routes.go`、`websocket_*_handlers.go`、`websocket_payloads.go`、`websocket_roundtrip.go`、`websocket_writer.go`、`websocket_connection_state.go` | +| v1 WebSocket server(弃用) | `crates/agent-gateway/internal/server/websocket.go`、`websocket_routes.go`、`websocket_*_handlers.go`、`websocket_payloads.go`、`websocket_roundtrip.go`、`websocket_terminal_stream.go` | | Session manager | `crates/agent-gateway/internal/session/manager.go`、`agent_session.go`、`manager_state.go`、`manager_registry.go`、`manager_*_sync.go`、`manager_terminal.go`、`manager_chat_runs.go` | | Auth | `crates/agent-gateway/internal/auth/*` | | Handlers | `crates/agent-gateway/internal/handler/*` | -| Proto source | `crates/agent-gateway/proto/v1/gateway.proto` | -| Generated proto | `crates/agent-gateway/internal/proto/v1/*` | +| Proto source | `crates/agent-gateway/proto/v1/gateway.proto`(业务消息)、`proto/v2/gateway_ws.proto`(v2 帧壳) | +| Generated proto | `crates/agent-gateway/internal/proto/v1/*`、`internal/proto/v2/*` | ## WebUI