From 6f99cab9926d52b8b007396f39709a7699e02866 Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Mon, 20 Jul 2026 12:43:18 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E6=A1=A5=E6=8E=A5=E9=80=9A=E4=BF=A1=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 85 ++ backend/biz/endpoint/bridge.go | 1355 +++++++++++++++++ backend/biz/endpoint/bridge_test.go | 624 ++++++++ backend/biz/endpoint/register.go | 33 + backend/biz/register.go | 3 + backend/config/config.go | 34 +- backend/db/client.go | 247 ++- backend/db/endpoint.go | 276 ++++ backend/db/endpoint/endpoint.go | 238 +++ backend/db/endpoint/where.go | 755 +++++++++ backend/db/endpoint_create.go | 1300 ++++++++++++++++ backend/db/endpoint_delete.go | 88 ++ backend/db/endpoint_query.go | 657 ++++++++ backend/db/endpoint_update.go | 820 ++++++++++ backend/db/ent.go | 2 + backend/db/hook/hook.go | 12 + backend/db/intercept/intercept.go | 30 + backend/db/migrate/schema.go | 43 + backend/db/mutation.go | 1202 ++++++++++++++- backend/db/page.go | 14 + backend/db/predicate/predicate.go | 3 + backend/db/runtime/runtime.go | 25 + backend/db/tx.go | 3 + backend/db/user.go | 24 +- backend/db/user/user.go | 30 + backend/db/user/where.go | 23 + backend/db/user_create.go | 32 + backend/db/user_query.go | 76 +- backend/db/user_update.go | 163 ++ backend/domain/endpoint.go | 59 + backend/ent/schema/endpoint.go | 52 + backend/ent/schema/user.go | 1 + backend/errcode/errcode.go | 19 +- backend/errcode/locale.en.toml | 2 + backend/errcode/locale.zh.toml | 2 + backend/pkg/session/session.go | 21 + desktop/electron/main.cjs | 153 +- desktop/electron/preload.cjs | 20 +- desktop/package.json | 3 + desktop/pnpm-lock.yaml | 18 + ...14\344\270\200\347\224\250\346\210\267.md" | 3 + ...06\345\210\253\347\253\257\347\202\271.md" | 3 + ...60\347\253\257\345\212\240\345\257\206.md" | 3 + ...50\347\272\277\350\267\257\347\224\261.md" | 3 + ...11\346\225\210\350\277\236\346\216\245.md" | 3 + ...50\346\210\267\344\274\232\350\257\235.md" | 3 + ...00\344\270\200\345\257\271\345\272\224.md" | 3 + ...67\347\253\257\347\273\264\346\212\244.md" | 3 + ...32\345\212\241\346\266\210\346\201\257.md" | 3 + ...ON\346\226\207\346\234\254\345\270\247.md" | 3 + ...2\214WebSocket\346\235\245\346\272\220.md" | 3 + ...57\346\234\254\350\267\257\347\224\261.md" | 3 + ...32\345\212\241\350\275\275\350\215\267.md" | 3 + ...57\347\202\271\346\234\254\345\234\260.md" | 3 + ...t-platform-interface-abstraction-design.md | 0 .../2026-07-14-skill-search-design.md | 0 ...26-07-15-task-skill-wheel-scroll-design.md | 0 ...026-07-17-client-bridge-client-protocol.md | 665 ++++++++ .../2026-07-17-client-bridge-protocol.md | 702 +++++++++ .../plans/2026-07-14-skill-search.md | 0 .../2026-07-15-task-skill-wheel-scroll.md | 0 .../src/components/app-runtime-provider.tsx | 2 + .../components/desktop-endpoint-bridge.tsx | 14 + frontend/src/lib/desktop-endpoint-client.ts | 190 +++ frontend/src/types/desktop.d.ts | 15 + mobile/app.json | 3 +- mobile/app/_layout.tsx | 5 +- mobile/package-lock.json | 49 + mobile/package.json | 2 + mobile/src/api/EndpointBridgeContext.tsx | 76 + .../src/api/__tests__/endpointBridge.test.ts | 162 ++ .../api/__tests__/endpointIdentity.test.ts | 38 + mobile/src/api/client.ts | 2 +- mobile/src/api/endpointBridge.ts | 513 +++++++ mobile/src/api/endpointIdentity.ts | 22 + 75 files changed, 10987 insertions(+), 62 deletions(-) create mode 100644 CONTEXT.md create mode 100644 backend/biz/endpoint/bridge.go create mode 100644 backend/biz/endpoint/bridge_test.go create mode 100644 backend/biz/endpoint/register.go create mode 100644 backend/db/endpoint.go create mode 100644 backend/db/endpoint/endpoint.go create mode 100644 backend/db/endpoint/where.go create mode 100644 backend/db/endpoint_create.go create mode 100644 backend/db/endpoint_delete.go create mode 100644 backend/db/endpoint_query.go create mode 100644 backend/db/endpoint_update.go create mode 100644 backend/domain/endpoint.go create mode 100644 backend/ent/schema/endpoint.go create mode 100644 "docs/adr/0001-\347\253\257\347\202\271\345\217\221\347\216\260\351\231\220\345\256\232\344\272\216\345\220\214\344\270\200\347\224\250\346\210\267.md" create mode 100644 "docs/adr/0002-\344\275\277\347\224\250\345\256\211\350\243\205\347\272\247\351\232\217\346\234\272\346\240\207\350\257\206\350\257\206\345\210\253\347\253\257\347\202\271.md" create mode 100644 "docs/adr/0003-\351\246\226\347\211\210\344\270\215\346\217\220\344\276\233\347\253\257\345\210\260\347\253\257\345\212\240\345\257\206.md" create mode 100644 "docs/adr/0004-\346\241\245\346\216\245\346\234\215\345\212\241\344\273\205\346\217\220\344\276\233\345\234\250\347\272\277\350\267\257\347\224\261.md" create mode 100644 "docs/adr/0005-\346\257\217\344\270\252\347\253\257\347\202\271\345\217\252\344\277\235\347\225\231\344\270\200\346\235\241\346\234\211\346\225\210\350\277\236\346\216\245.md" create mode 100644 "docs/adr/0006-\347\253\257\347\202\271\351\211\264\346\235\203\345\244\215\347\224\250\347\224\250\346\210\267\344\274\232\350\257\235.md" create mode 100644 "docs/adr/0007-\347\253\257\347\202\271\344\270\216Agent\344\270\200\344\270\200\345\257\271\345\272\224.md" create mode 100644 "docs/adr/0008-\350\257\267\346\261\202\347\212\266\346\200\201\347\224\261\345\256\242\346\210\267\347\253\257\347\273\264\346\212\244.md" create mode 100644 "docs/adr/0009-\346\240\270\345\277\203\345\215\217\350\256\256\344\270\215\350\207\252\345\212\250\351\207\215\350\257\225\344\270\232\345\212\241\346\266\210\346\201\257.md" create mode 100644 "docs/adr/0010-\346\240\270\345\277\203\345\215\217\350\256\256\344\275\277\347\224\250JSON\346\226\207\346\234\254\345\270\247.md" create mode 100644 "docs/adr/0011-\346\241\245\346\216\245\350\277\236\346\216\245\346\240\241\351\252\214WebSocket\346\235\245\346\272\220.md" create mode 100644 "docs/adr/0012-\346\241\245\346\216\245\346\234\215\345\212\241\346\224\257\346\214\201\345\244\232\345\211\257\346\234\254\350\267\257\347\224\261.md" create mode 100644 "docs/adr/0013-\346\241\245\346\216\245\346\234\215\345\212\241\344\270\215\350\256\260\345\275\225\344\270\232\345\212\241\350\275\275\350\215\267.md" create mode 100644 "docs/adr/0014-\346\226\207\344\273\266\344\277\235\347\225\231\345\234\250\346\272\220\347\253\257\347\202\271\346\234\254\345\234\260.md" rename docs/{superpowers/specs => design}/2026-03-27-git-platform-interface-abstraction-design.md (100%) rename docs/{superpowers/specs => design}/2026-07-14-skill-search-design.md (100%) rename docs/{superpowers/specs => design}/2026-07-15-task-skill-wheel-scroll-design.md (100%) create mode 100644 docs/design/2026-07-17-client-bridge-client-protocol.md create mode 100644 docs/design/2026-07-17-client-bridge-protocol.md rename docs/{superpowers => }/plans/2026-07-14-skill-search.md (100%) rename docs/{superpowers => }/plans/2026-07-15-task-skill-wheel-scroll.md (100%) create mode 100644 frontend/src/components/desktop-endpoint-bridge.tsx create mode 100644 frontend/src/lib/desktop-endpoint-client.ts create mode 100644 frontend/src/types/desktop.d.ts create mode 100644 mobile/src/api/EndpointBridgeContext.tsx create mode 100644 mobile/src/api/__tests__/endpointBridge.test.ts create mode 100644 mobile/src/api/__tests__/endpointIdentity.test.ts create mode 100644 mobile/src/api/endpointBridge.ts create mode 100644 mobile/src/api/endpointIdentity.ts diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..4ae3ea73f --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,85 @@ +# MonkeyCode 客户端协作通信 + +该上下文定义 MonkeyCode 客户端之间发现、寻址与协作时使用的共同语言。 + +## Language + +**端点(Endpoint)**: +在 MonkeyCode 客户端间通信中可被发现和寻址的原生应用安装实例,与一个 Agent 一一对应。任务是端点管理的资源,不是端点本身。 +_Avoid_: 客户端、设备、任务 + +**Agent**: +通过端点参与通信的执行主体,与端点共享一对一身份。 +_Avoid_: 子 Agent、任务、能力 + +**发现域(Discovery Domain)**: +允许互相发现的一组端点所构成的安全边界。 +_Avoid_: 团队、项目、租户 + +**机器标识(Machine ID)**: +MonkeyCode 首次安装时生成并在本地安全保存的随机 UUID,与用户身份共同标识一个端点。 +_Avoid_: 硬件指纹、MAC 地址、硬盘序列号 + +**桥接服务(Bridge)**: +连接端点并负责鉴权、发现和消息路由的中心服务,不参与端点载荷所表达的业务行为。 +_Avoid_: Agent、任务服务 + +**载荷(Payload)**: +端点之间交换的业务数据,其语义由通信双方约定,对桥接服务没有领域含义。 +_Avoid_: 路由信息、协议信封 + +**本地文件(Local File)**: +由源端点持有和保存、其他端点通过 Agent 自定义协议访问的文件。 +_Avoid_: OSS 对象、远程附件、桥接服务文件 + +**业务消息(Agent Message)**: +一个 Agent 明确发送给另一个目标 Agent 的单播消息。 +_Avoid_: 广播、目录快照 + +**消息标识(Message ID)**: +由发送方生成、用于唯一识别一条业务消息的 UUID;响应通过引用请求的消息标识建立关联。 +_Avoid_: 请求状态、任务标识、端点标识 + +**事件消息(Event Message)**: +发送方不要求目标 Agent 返回结果的业务消息。 +_Avoid_: 目录事件、请求 + +**请求消息(Request Message)**: +要求目标 Agent 返回关联结果的业务消息。 +_Avoid_: 事件、通知 + +**响应消息(Response Message)**: +引用某个请求并携带其处理结果的业务消息。 +_Avoid_: 新请求、送达回执 + +**协议错误(Protocol Error)**: +桥接服务因鉴权、信封校验或路由失败而产生的错误,与 Agent 返回的业务结果无关。 +_Avoid_: 业务失败、Agent 响应 + +**结果未知(Outcome Unknown)**: +请求未在发送方等待期限内得到响应,因而无法判断目标 Agent 是否已经执行。 +_Avoid_: 执行失败、未送达 + +**目录快照(Directory Snapshot)**: +发现域内全部未撤销端点的当前视图,在连接就绪及目录发生变化时整体发送。 +_Avoid_: 增量事件、目录补丁 + +**在线路由(Online Routing)**: +消息只被投递给当前在线的目标端点,目标离线时不会被桥接服务留存等待补发。 +_Avoid_: 离线消息、消息队列、存储转发 + +**端点目录(Endpoint Directory)**: +发现域内已登记端点的持久化集合,包含在线和离线端点。 +_Avoid_: 在线连接列表、消息订阅列表 + +**端点资料(Endpoint Profile)**: +用于帮助用户辨认端点和判断兼容性的最小公开信息,包括名称、操作系统、架构及客户端与协议版本。 +_Avoid_: 硬件清单、用户信息、网络地址 + +**在线状态(Presence)**: +端点当前是否与桥接服务保持有效连接的临时状态,不影响端点在目录中的身份。 +_Avoid_: 端点身份、注册状态 + +**已撤销端点(Revoked Endpoint)**: +被暂停桥接连接资格的端点,其身份记录仍被保留以阻止自动重新登记;持有有效用户会话的客户端可以显式恢复。 +_Avoid_: 已删除端点、离线端点 diff --git a/backend/biz/endpoint/bridge.go b/backend/biz/endpoint/bridge.go new file mode 100644 index 000000000..b5ec4e387 --- /dev/null +++ b/backend/biz/endpoint/bridge.go @@ -0,0 +1,1355 @@ +package endpoint + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "regexp" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/GoYoko/web" + "github.com/coder/websocket" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/redis/go-redis/v9" + + "github.com/chaitin/MonkeyCode/backend/config" + "github.com/chaitin/MonkeyCode/backend/consts" + "github.com/chaitin/MonkeyCode/backend/db" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/errcode" + "github.com/chaitin/MonkeyCode/backend/middleware" + "github.com/chaitin/MonkeyCode/backend/pkg/session" +) + +const ( + protocolVersion = 1 + directoryChannel = "endpoint:directory" +) + +var methodPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,127}$`) + +type bridgeConfig struct { + maxEndpoints int + maxFrameBytes int64 + queueMessages int + queueBytes int64 + messageRate int + byteRate int64 + heartbeatInterval time.Duration + heartbeatTimeout time.Duration + presenceTTL time.Duration + allowedOrigins []string + debug bool +} + +type Bridge struct { + db *db.Client + redis *redis.Client + session *session.Session + logger *slog.Logger + instanceID string + config bridgeConfig + ctx context.Context + cancel context.CancelFunc + pubsub *redis.PubSub + connsMu sync.RWMutex + conns map[string]*clientConn + presenceMu sync.Mutex + presenceState map[uuid.UUID]string + subscriptionReady atomic.Bool + closed atomic.Bool +} + +type clientConn struct { + userID uuid.UUID + machineID uuid.UUID + generation string + cookie string + conn *websocket.Conn + queue chan []byte + queueMu sync.Mutex + queueBytes int64 + rateViolations int + done chan struct{} + closeOnce sync.Once +} + +type presence struct { + InstanceID string `json:"instance_id"` + Generation string `json:"generation"` +} + +type routedMessage struct { + Kind string `json:"kind"` + UserID uuid.UUID `json:"user_id"` + Target uuid.UUID `json:"target"` + TargetGeneration string `json:"target_generation"` + Source uuid.UUID `json:"source,omitempty"` + SourceInstance string `json:"source_instance,omitempty"` + SourceGeneration string `json:"source_generation,omitempty"` + ReplyTo uuid.UUID `json:"reply_to,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +type directoryEvent struct { + UserID uuid.UUID `json:"user_id"` +} + +func newBridge(client *db.Client, rdb *redis.Client, cfg *config.Config, logger *slog.Logger) *Bridge { + instanceID := strings.TrimSpace(cfg.EndpointBridge.InstanceID) + if instanceID == "" { + host, _ := os.Hostname() + instanceID = host + "-" + uuid.NewString() + } + ctx, cancel := context.WithCancel(context.Background()) + b := &Bridge{ + db: client, + redis: rdb, + logger: logger.With("module", "endpoint.bridge", "instance_id", instanceID), + instanceID: instanceID, + config: resolveBridgeConfig(cfg), + ctx: ctx, + cancel: cancel, + conns: make(map[string]*clientConn), + presenceState: make(map[uuid.UUID]string), + } + b.pubsub = rdb.Subscribe(ctx, directoryChannel, instanceChannel(instanceID)) + if _, err := b.pubsub.ReceiveTimeout(ctx, time.Second); err != nil { + b.logger.WarnContext(ctx, "订阅端点路由频道失败", "error", err) + } else { + b.subscriptionReady.Store(true) + } + go b.consumePubSub() + go b.watchPresence() + return b +} + +func resolveBridgeConfig(cfg *config.Config) bridgeConfig { + duration := func(value string, fallback time.Duration) time.Duration { + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + return fallback + } + return parsed + } + positive := func(value, fallback int) int { + if value <= 0 { + return fallback + } + return value + } + positive64 := func(value, fallback int64) int64 { + if value <= 0 { + return fallback + } + return value + } + return bridgeConfig{ + maxEndpoints: positive(cfg.EndpointBridge.MaxEndpoints, 20), + maxFrameBytes: positive64(cfg.EndpointBridge.MaxFrameBytes, 256<<10), + queueMessages: positive(cfg.EndpointBridge.QueueMessages, 128), + queueBytes: positive64(cfg.EndpointBridge.QueueBytes, 4<<20), + messageRate: positive(cfg.EndpointBridge.MessageRate, 100), + byteRate: positive64(cfg.EndpointBridge.ByteRate, 4<<20), + heartbeatInterval: duration(cfg.EndpointBridge.HeartbeatInterval, 30*time.Second), + heartbeatTimeout: duration(cfg.EndpointBridge.HeartbeatTimeout, 10*time.Second), + presenceTTL: duration(cfg.EndpointBridge.PresenceTTL, 60*time.Second), + allowedOrigins: slices.Clone(cfg.EndpointBridge.AllowedOrigins), + debug: cfg.Debug, + } +} + +func (b *Bridge) Register(w *web.Web, auth *middleware.AuthMiddleware) { + b.session = auth.Session + group := w.Group("/api/v1/endpoints") + endpointAuth := b.endpointAuth(auth) + group.Use(endpointAuth) + group.GET("", web.BaseHandler(b.List)) + group.GET("/connect", web.BaseHandler(b.Connect)) + group.GET("/:machine_id", web.BindHandler(b.Get)) + group.POST("/:machine_id/revoke", web.BindHandler(b.Revoke)) + group.POST("/:machine_id/restore", web.BindHandler(b.Restore)) + w.Echo().PATCH("/api/v1/endpoints/:machine_id", b.updateEndpoint, endpointAuth) + w.Echo().Server.RegisterOnShutdown(b.Close) + w.Echo().TLSServer.RegisterOnShutdown(b.Close) +} + +func (b *Bridge) endpointAuth(auth *middleware.AuthMiddleware) echo.MiddlewareFunc { + protected := auth.Auth() + return func(next echo.HandlerFunc) echo.HandlerFunc { + handler := protected(next) + return func(c echo.Context) error { + err := handler(c) + if c.Response().Status == http.StatusUnauthorized { + b.logger.WarnContext(c.Request().Context(), "端点鉴权失败", + "security_audit", true, + "event", "endpoint.authentication_failed", + "path", c.Path(), + "source_ip", c.RealIP(), + "user_agent", c.Request().UserAgent(), + ) + } + return err + } + } +} + +func (b *Bridge) Close() { + if !b.closed.CompareAndSwap(false, true) { + return + } + b.cancel() + if b.pubsub != nil { + _ = b.pubsub.Close() + } + b.connsMu.RLock() + conns := make([]*clientConn, 0, len(b.conns)) + for _, conn := range b.conns { + conns = append(conns, conn) + } + b.connsMu.RUnlock() + for _, conn := range conns { + conn.close(websocket.StatusServiceRestart, "service restart") + } +} + +func (b *Bridge) List(c *web.Context) error { + user := middleware.GetUser(c) + items, err := b.listEndpoints(c.Request().Context(), user.ID, true) + if err != nil { + return err + } + return c.Success(items) +} + +func (b *Bridge) Get(c *web.Context, req domain.EndpointPathReq) error { + user := middleware.GetUser(c) + item, err := b.db.Endpoint.Query(). + Where(endpoint.UserID(user.ID), endpoint.MachineID(req.MachineID)). + Only(c.Request().Context()) + if db.IsNotFound(err) { + return errcode.ErrNotFound + } + if err != nil { + return err + } + view := b.endpointView(c.Request().Context(), item, true) + return c.Success(view) +} + +func (b *Bridge) updateEndpoint(c echo.Context) error { + machineID, err := uuid.Parse(c.Param("machine_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]any{"code": http.StatusBadRequest, "message": "machine_id 无效"}) + } + var body struct { + Alias *string `json:"alias"` + } + if err := c.Bind(&body); err != nil { + return c.JSON(http.StatusBadRequest, map[string]any{"code": http.StatusBadRequest, "message": "请求体无效"}) + } + user := middleware.GetUser(c) + query := b.db.Endpoint.Update().Where(endpoint.UserID(user.ID), endpoint.MachineID(machineID)) + if body.Alias == nil || strings.TrimSpace(*body.Alias) == "" { + query.ClearAlias() + } else { + alias := strings.TrimSpace(*body.Alias) + if len([]rune(alias)) > 128 { + return c.JSON(http.StatusBadRequest, map[string]any{"code": http.StatusBadRequest, "message": "端点别名过长"}) + } + query.SetAlias(alias) + } + affected, err := query.Save(c.Request().Context()) + if err != nil { + return err + } + if affected == 0 { + return c.JSON(http.StatusNotFound, map[string]any{"code": http.StatusNotFound, "message": "端点不存在"}) + } + b.publishDirectory(user.ID) + item, err := b.db.Endpoint.Query(). + Where(endpoint.UserID(user.ID), endpoint.MachineID(machineID)). + Only(c.Request().Context()) + if err != nil { + return err + } + return c.JSON(http.StatusOK, map[string]any{ + "code": 0, + "message": "success", + "data": b.endpointView(c.Request().Context(), item, true), + }) +} + +func (b *Bridge) Revoke(c *web.Context, req domain.EndpointPathReq) error { + user := middleware.GetUser(c) + affected, err := b.db.Endpoint.Update(). + Where(endpoint.UserID(user.ID), endpoint.MachineID(req.MachineID)). + SetStatus(domain.EndpointStatusRevoked). + Save(c.Request().Context()) + if err != nil { + return err + } + if affected == 0 { + return errcode.ErrNotFound + } + b.disconnectEndpoint(c.Request().Context(), user.ID, req.MachineID, websocket.StatusCode(4002), "endpoint revoked") + b.publishDirectory(user.ID) + b.logger.InfoContext(c.Request().Context(), "端点已撤销", + "security_audit", true, + "event", "endpoint.revoked", + "user_id", user.ID, + "machine_id", req.MachineID, + ) + return c.Success(map[string]any{"machine_id": req.MachineID, "status": domain.EndpointStatusRevoked}) +} + +func (b *Bridge) Restore(c *web.Context, req domain.EndpointPathReq) error { + ctx := c.Request().Context() + user := middleware.GetUser(c) + item, err := b.db.Endpoint.Query(). + Where(endpoint.UserID(user.ID), endpoint.MachineID(req.MachineID)). + Only(ctx) + if db.IsNotFound(err) { + return errcode.ErrNotFound + } + if err != nil { + return err + } + if item.Status == domain.EndpointStatusActive { + return c.Success(map[string]any{"machine_id": req.MachineID, "status": domain.EndpointStatusActive}) + } + unlock, err := b.acquireRegistrationLock(ctx, user.ID) + if err != nil { + return err + } + defer unlock() + item, err = b.db.Endpoint.Query(). + Where(endpoint.UserID(user.ID), endpoint.MachineID(req.MachineID)). + Only(ctx) + if db.IsNotFound(err) { + return errcode.ErrNotFound + } + if err != nil { + return err + } + if item.Status == domain.EndpointStatusActive { + return c.Success(map[string]any{"machine_id": req.MachineID, "status": domain.EndpointStatusActive}) + } + count, err := b.db.Endpoint.Query(). + Where(endpoint.UserID(user.ID), endpoint.StatusEQ(domain.EndpointStatusActive)). + Count(ctx) + if err != nil { + return err + } + if count >= b.config.maxEndpoints { + return errcode.ErrEndpointLimitExceeded + } + affected, err := b.db.Endpoint.Update(). + Where(endpoint.UserID(user.ID), endpoint.MachineID(req.MachineID)). + SetStatus(domain.EndpointStatusActive). + Save(ctx) + if err != nil { + return err + } + if affected == 0 { + return errcode.ErrNotFound + } + b.publishDirectory(user.ID) + b.logger.InfoContext(ctx, "端点已恢复", + "security_audit", true, + "event", "endpoint.restored", + "user_id", user.ID, + "machine_id", req.MachineID, + ) + return c.Success(map[string]any{"machine_id": req.MachineID, "status": domain.EndpointStatusActive}) +} + +func (b *Bridge) Connect(c *web.Context) error { + if err := b.validateUpgrade(c.Request()); err != nil { + return c.String(http.StatusForbidden, err.Error()) + } + user := middleware.GetUser(c) + cookie, err := c.Cookie(consts.MonkeyCodeAISession) + if err != nil { + return c.String(http.StatusUnauthorized, "Unauthorized") + } + conn, err := websocket.Accept(c.Response(), c.Request(), &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + OriginPatterns: b.config.allowedOrigins, + }) + if err != nil { + return nil + } + conn.SetReadLimit(b.config.maxFrameBytes) + + hello, err := b.readHello(conn) + if err != nil { + b.closeForError(conn, err) + return nil + } + record, err := b.upsertEndpoint(c.Request().Context(), user.ID, hello) + if err != nil { + b.writeHandshakeError(conn, err) + return nil + } + client, err := b.attach(c.Request().Context(), user.ID, hello.MachineID, cookie.Value, conn) + if err != nil { + b.writeHandshakeError(conn, err) + return nil + } + defer b.detach(client) + + go b.writeLoop(client) + b.enqueueSystem(client, map[string]any{ + "type": "welcome", + "protocol_version": protocolVersion, + "server_time": time.Now().UnixMilli(), + "heartbeat": map[string]any{ + "interval_ms": b.config.heartbeatInterval.Milliseconds(), + "timeout_ms": b.config.heartbeatTimeout.Milliseconds(), + }, + "limits": map[string]any{ + "max_frame_bytes": b.config.maxFrameBytes, + "max_endpoints": b.config.maxEndpoints, + }, + }) + _ = record + b.sendDirectory(client) + b.publishDirectory(user.ID) + go b.heartbeat(client) + b.readLoop(client) + return nil +} + +func (b *Bridge) validateUpgrade(r *http.Request) error { + secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") + if !secure && (!b.config.debug || !isLoopbackHost(r.Host)) { + return errors.New("只允许 WSS,开发环境本机地址除外") + } + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return nil + } + for _, allowed := range b.config.allowedOrigins { + if origin == strings.TrimSpace(allowed) { + return nil + } + } + return errors.New("来源不受信任") +} + +func isLoopbackHost(address string) bool { + host := address + if parsed, _, err := net.SplitHostPort(address); err == nil { + host = parsed + } + host = strings.Trim(host, "[]") + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func (b *Bridge) readHello(conn *websocket.Conn) (domain.EndpointHello, error) { + ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second) + defer cancel() + messageType, raw, err := conn.Read(ctx) + if err != nil { + return domain.EndpointHello{}, err + } + if messageType != websocket.MessageText { + return domain.EndpointHello{}, errUnsupportedData + } + if int64(len(raw)) > b.config.maxFrameBytes { + return domain.EndpointHello{}, errFrameTooLarge + } + var hello domain.EndpointHello + if err := json.Unmarshal(raw, &hello); err != nil { + return hello, errInvalidMessage + } + if hello.Type != "hello" || hello.MachineID == uuid.Nil || !slices.Contains(hello.ProtocolVersions, protocolVersion) { + return hello, errUnsupportedProtocol + } + if !validProfile(hello.Profile) { + return hello, errInvalidMessage + } + return hello, nil +} + +func validProfile(profile domain.EndpointProfile) bool { + if strings.TrimSpace(profile.DeviceName) == "" || + strings.TrimSpace(profile.OSVersion) == "" || + strings.TrimSpace(profile.Arch) == "" || + strings.TrimSpace(profile.ClientVersion) == "" { + return false + } + switch profile.Platform { + case "macos", "windows", "linux", "ios", "android": + return true + default: + return false + } +} + +func (b *Bridge) upsertEndpoint(ctx context.Context, userID uuid.UUID, hello domain.EndpointHello) (*db.Endpoint, error) { + item, err := b.db.Endpoint.Query(). + Where(endpoint.UserID(userID), endpoint.MachineID(hello.MachineID)). + Only(ctx) + if err == nil { + if item.Status == domain.EndpointStatusRevoked { + return nil, errEndpointRevoked + } + return item.Update(). + SetDeviceName(hello.Profile.DeviceName). + SetPlatform(endpoint.Platform(hello.Profile.Platform)). + SetOsVersion(hello.Profile.OSVersion). + SetArch(hello.Profile.Arch). + SetClientVersion(hello.Profile.ClientVersion). + SetProtocolVersion(protocolVersion). + SetLastSeenAt(time.Now()). + Save(ctx) + } + if !db.IsNotFound(err) { + return nil, err + } + unlock, err := b.acquireRegistrationLock(ctx, userID) + if err != nil { + return nil, err + } + defer unlock() + item, err = b.db.Endpoint.Query(). + Where(endpoint.UserID(userID), endpoint.MachineID(hello.MachineID)). + Only(ctx) + if err == nil { + if item.Status == domain.EndpointStatusRevoked { + return nil, errEndpointRevoked + } + return item.Update(). + SetDeviceName(hello.Profile.DeviceName). + SetPlatform(endpoint.Platform(hello.Profile.Platform)). + SetOsVersion(hello.Profile.OSVersion). + SetArch(hello.Profile.Arch). + SetClientVersion(hello.Profile.ClientVersion). + SetProtocolVersion(protocolVersion). + SetLastSeenAt(time.Now()). + Save(ctx) + } + if !db.IsNotFound(err) { + return nil, err + } + count, err := b.db.Endpoint.Query(). + Where(endpoint.UserID(userID), endpoint.StatusEQ(domain.EndpointStatusActive)). + Count(ctx) + if err != nil { + return nil, err + } + if count >= b.config.maxEndpoints { + return nil, errEndpointLimit + } + item, err = b.db.Endpoint.Create(). + SetUserID(userID). + SetMachineID(hello.MachineID). + SetDeviceName(hello.Profile.DeviceName). + SetPlatform(endpoint.Platform(hello.Profile.Platform)). + SetOsVersion(hello.Profile.OSVersion). + SetArch(hello.Profile.Arch). + SetClientVersion(hello.Profile.ClientVersion). + SetProtocolVersion(protocolVersion). + SetLastSeenAt(time.Now()). + Save(ctx) + if err == nil { + b.logger.InfoContext(ctx, "端点已登记", + "security_audit", true, + "event", "endpoint.registered", + "user_id", userID, + "machine_id", hello.MachineID, + ) + } + return item, err +} + +func (b *Bridge) attach(ctx context.Context, userID, machineID uuid.UUID, cookie string, conn *websocket.Conn) (*clientConn, error) { + if !b.subscriptionReady.Load() { + return nil, errServiceUnavailable + } + if err := b.redis.Ping(ctx).Err(); err != nil { + return nil, errServiceUnavailable + } + generation := uuid.NewString() + location := presence{InstanceID: b.instanceID, Generation: generation} + raw, _ := json.Marshal(location) + key := presenceKey(userID, machineID) + oldRaw, err := replacePresenceScript.Run(ctx, b.redis, []string{key}, string(raw), b.config.presenceTTL.Milliseconds()).Text() + if err != nil && !errors.Is(err, redis.Nil) { + return nil, errServiceUnavailable + } + client := &clientConn{ + userID: userID, + machineID: machineID, + generation: generation, + cookie: cookie, + conn: conn, + queue: make(chan []byte, b.config.queueMessages), + done: make(chan struct{}), + } + connKey := localConnectionKey(userID, machineID) + b.connsMu.Lock() + oldLocal := b.conns[connKey] + b.conns[connKey] = client + b.connsMu.Unlock() + replaced := oldLocal != nil && oldLocal != client + if oldLocal != nil && oldLocal != client { + oldLocal.close(websocket.StatusCode(4001), "connection replaced") + } + if oldRaw != "" { + var old presence + if json.Unmarshal([]byte(oldRaw), &old) == nil && old.Generation != generation && old.InstanceID != b.instanceID { + replaced = true + command, _ := json.Marshal(routedMessage{ + Kind: "close", + UserID: userID, + Target: machineID, + TargetGeneration: old.Generation, + }) + _ = b.redis.Publish(ctx, instanceChannel(old.InstanceID), command).Err() + } + } + if replaced { + b.logger.InfoContext(ctx, "端点连接已替换", + "security_audit", true, + "event", "endpoint.connection_replaced", + "user_id", userID, + "machine_id", machineID, + ) + } + return client, nil +} + +func (b *Bridge) detach(client *clientConn) { + client.close(websocket.StatusNormalClosure, "") + b.connsMu.Lock() + key := localConnectionKey(client.userID, client.machineID) + if b.conns[key] == client { + delete(b.conns, key) + } + b.connsMu.Unlock() + _, _ = b.db.Endpoint.Update(). + Where(endpoint.UserID(client.userID), endpoint.MachineID(client.machineID)). + SetLastSeenAt(time.Now()). + Save(context.Background()) + _, _ = deletePresenceScript.Run( + context.Background(), + b.redis, + []string{presenceKey(client.userID, client.machineID)}, + b.instanceID, + client.generation, + ).Result() + b.publishDirectory(client.userID) +} + +func (b *Bridge) readLoop(client *clientConn) { + for { + messageType, raw, err := client.conn.Read(b.ctx) + if err != nil { + return + } + if messageType != websocket.MessageText { + client.close(websocket.StatusUnsupportedData, "text frames only") + return + } + if int64(len(raw)) > b.config.maxFrameBytes { + client.close(websocket.StatusMessageTooBig, "frame too large") + return + } + envelope, err := normalizeEnvelope(raw, client) + if err != nil { + b.enqueueError(client, envelope.MessageID, protocolErrorCode(err), false, 0) + continue + } + if !b.allowMessage(client, len(raw)) { + client.rateViolations++ + b.enqueueError(client, envelope.MessageID, "rate_limited", true, time.Second) + if client.rateViolations >= 3 { + client.close(websocket.StatusPolicyViolation, "rate limit exceeded") + return + } + continue + } + client.rateViolations = 0 + b.route(client, envelope) + } +} + +func normalizeEnvelope(raw []byte, client *clientConn) (domain.EndpointEnvelope, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return domain.EndpointEnvelope{}, errInvalidMessage + } + if _, exists := fields["source"]; exists { + return domain.EndpointEnvelope{}, errInvalidMessage + } + if _, exists := fields["routed_at"]; exists { + return domain.EndpointEnvelope{}, errInvalidMessage + } + var envelope domain.EndpointEnvelope + if err := json.Unmarshal(raw, &envelope); err != nil { + return envelope, errInvalidMessage + } + if envelope.MessageID == uuid.Nil || envelope.MessageID.Version() != 4 || envelope.Target == uuid.Nil { + return envelope, errInvalidMessage + } + var payload map[string]any + if len(envelope.Payload) == 0 || json.Unmarshal(envelope.Payload, &payload) != nil || payload == nil { + return envelope, errInvalidMessage + } + switch envelope.Type { + case "event", "request": + if !methodPattern.MatchString(envelope.Method) || envelope.ReplyTo != nil { + return envelope, errInvalidMessage + } + case "response": + if envelope.ReplyTo == nil || envelope.ReplyTo.Version() != 4 || envelope.Method != "" { + return envelope, errInvalidMessage + } + default: + return envelope, errInvalidMessage + } + source := client.machineID + now := time.Now().UnixMilli() + envelope.Source = &source + envelope.RoutedAt = &now + return envelope, nil +} + +func (b *Bridge) route(source *clientConn, envelope domain.EndpointEnvelope) { + ctx := b.ctx + exists, err := b.db.Endpoint.Query(). + Where( + endpoint.UserID(source.userID), + endpoint.MachineID(envelope.Target), + endpoint.StatusEQ(domain.EndpointStatusActive), + ). + Exist(ctx) + if err != nil { + b.enqueueError(source, envelope.MessageID, "service_unavailable", true, time.Second) + return + } + if !exists { + b.enqueueError(source, envelope.MessageID, "target_unavailable", false, 0) + return + } + rawLocation, err := b.redis.Get(ctx, presenceKey(source.userID, envelope.Target)).Bytes() + if errors.Is(err, redis.Nil) { + b.enqueueError(source, envelope.MessageID, "target_offline", true, time.Second) + return + } + if err != nil { + b.enqueueError(source, envelope.MessageID, "service_unavailable", true, time.Second) + return + } + var location presence + if json.Unmarshal(rawLocation, &location) != nil { + b.enqueueError(source, envelope.MessageID, "stale_route", true, 0) + return + } + payload, err := json.Marshal(envelope) + if err != nil || int64(len(payload)) > b.config.maxFrameBytes { + b.enqueueError(source, envelope.MessageID, "payload_too_large", false, 0) + return + } + if location.InstanceID == b.instanceID { + target := b.localConnection(source.userID, envelope.Target) + if target == nil || target.generation != location.Generation { + b.enqueueError(source, envelope.MessageID, "stale_route", true, 0) + return + } + if !b.enqueueBusiness(target, payload) { + b.enqueueError(source, envelope.MessageID, "target_busy", true, time.Second) + } + return + } + message, _ := json.Marshal(routedMessage{ + Kind: "route", + UserID: source.userID, + Target: envelope.Target, + TargetGeneration: location.Generation, + Source: source.machineID, + SourceInstance: b.instanceID, + SourceGeneration: source.generation, + ReplyTo: envelope.MessageID, + Payload: payload, + }) + subscribers, err := b.redis.Publish(ctx, instanceChannel(location.InstanceID), message).Result() + if err != nil || subscribers == 0 { + b.enqueueError(source, envelope.MessageID, "target_unavailable", true, time.Second) + } +} + +func (b *Bridge) allowMessage(client *clientConn, size int) bool { + now := time.Now().Unix() + userKey := fmt.Sprintf("endpoint:rate:user:%s:%d", client.userID, now) + clientKey := fmt.Sprintf("endpoint:rate:client:%s:%s:%d", client.userID, client.machineID, now) + values, err := rateScript.Run( + b.ctx, + b.redis, + []string{userKey, clientKey}, + size, + b.config.messageRate, + b.config.byteRate, + ).Int64Slice() + return err == nil && len(values) == 4 && + values[0] <= int64(b.config.messageRate) && + values[1] <= b.config.byteRate && + values[2] <= int64(b.config.messageRate) && + values[3] <= b.config.byteRate +} + +func (b *Bridge) writeLoop(client *clientConn) { + for { + select { + case <-client.done: + return + case payload := <-client.queue: + ctx, cancel := context.WithTimeout(b.ctx, b.config.heartbeatTimeout) + err := client.conn.Write(ctx, websocket.MessageText, payload) + cancel() + client.queueMu.Lock() + client.queueBytes -= int64(len(payload)) + client.queueMu.Unlock() + if err != nil { + client.close(websocket.StatusInternalError, "write failed") + return + } + } + } +} + +func (b *Bridge) enqueueBusiness(client *clientConn, payload []byte) bool { + client.queueMu.Lock() + defer client.queueMu.Unlock() + if client.queueBytes+int64(len(payload)) > b.config.queueBytes { + return false + } + select { + case client.queue <- payload: + client.queueBytes += int64(len(payload)) + return true + default: + return false + } +} + +func (b *Bridge) enqueueSystem(client *clientConn, value any) { + payload, err := json.Marshal(value) + if err != nil { + return + } + if !b.enqueueBusiness(client, payload) { + client.close(websocket.StatusTryAgainLater, "client too slow") + } +} + +func (b *Bridge) enqueueError(client *clientConn, replyTo uuid.UUID, code string, retryable bool, retryAfter time.Duration) { + message := map[string]any{ + "type": "error", + "error": map[string]any{ + "code": code, + "message": protocolErrorMessage(code), + "retryable": retryable, + }, + } + if replyTo != uuid.Nil { + message["reply_to"] = replyTo + } + if retryAfter > 0 { + message["error"].(map[string]any)["retry_after_ms"] = retryAfter.Milliseconds() + } + b.enqueueSystem(client, message) +} + +func (b *Bridge) heartbeat(client *clientConn) { + ticker := time.NewTicker(b.config.heartbeatInterval) + defer ticker.Stop() + for { + select { + case <-client.done: + return + case <-ticker.C: + ctx, cancel := context.WithTimeout(b.ctx, b.config.heartbeatTimeout) + if err := client.conn.Ping(ctx); err != nil { + cancel() + client.close(websocket.StatusInternalError, "heartbeat failed") + return + } + valid, err := b.session.Check(ctx, consts.MonkeyCodeAISession, client.cookie) + if err != nil { + cancel() + client.close(websocket.StatusTryAgainLater, "session check unavailable") + return + } + if !valid { + cancel() + b.logger.Info("端点会话已失效", + "security_audit", true, + "event", "endpoint.session_expired", + "user_id", client.userID, + "machine_id", client.machineID, + ) + client.close(websocket.StatusCode(4003), "session expired") + return + } + renewed, err := renewPresenceScript.Run( + ctx, + b.redis, + []string{presenceKey(client.userID, client.machineID)}, + b.instanceID, + client.generation, + b.config.presenceTTL.Milliseconds(), + ).Int() + cancel() + if err != nil || renewed < 0 { + client.close(websocket.StatusTryAgainLater, "presence unavailable") + return + } + if renewed == 0 { + client.close(websocket.StatusCode(4001), "connection replaced") + return + } + } + } +} + +func (b *Bridge) sendDirectory(client *clientConn) { + items, err := b.listEndpoints(b.ctx, client.userID, false) + if err != nil { + b.enqueueError(client, uuid.Nil, "service_unavailable", true, time.Second) + return + } + b.enqueueSystem(client, map[string]any{"type": "directory.snapshot", "endpoints": items}) +} + +func (b *Bridge) listEndpoints(ctx context.Context, userID uuid.UUID, includeRevoked bool) ([]domain.EndpointView, error) { + query := b.db.Endpoint.Query().Where(endpoint.UserID(userID)).Order(endpoint.ByCreatedAt()) + if !includeRevoked { + query.Where(endpoint.StatusEQ(domain.EndpointStatusActive)) + } + items, err := query.All(ctx) + if err != nil { + return nil, err + } + views := make([]domain.EndpointView, 0, len(items)) + for _, item := range items { + views = append(views, b.endpointView(ctx, item, includeRevoked)) + } + return views, nil +} + +func (b *Bridge) endpointView(ctx context.Context, item *db.Endpoint, includeStatus bool) domain.EndpointView { + createdAt := item.CreatedAt.UnixMilli() + updatedAt := item.UpdatedAt.UnixMilli() + var lastSeen *int64 + if item.LastSeenAt != nil { + value := item.LastSeenAt.UnixMilli() + lastSeen = &value + } + displayName := item.DeviceName + if item.Alias != nil { + displayName = *item.Alias + } + view := domain.EndpointView{ + MachineID: item.MachineID, + DeviceName: item.DeviceName, + Alias: item.Alias, + DisplayName: displayName, + Platform: string(item.Platform), + OSVersion: item.OsVersion, + Arch: item.Arch, + ClientVersion: item.ClientVersion, + ProtocolVersion: item.ProtocolVersion, + Online: b.redis.Exists(ctx, presenceKey(item.UserID, item.MachineID)).Val() == 1, + LastSeenAt: lastSeen, + } + if includeStatus { + view.Status = string(item.Status) + view.CreatedAt = &createdAt + view.UpdatedAt = &updatedAt + } + return view +} + +func (b *Bridge) publishDirectory(userID uuid.UUID) { + payload, _ := json.Marshal(directoryEvent{UserID: userID}) + if err := b.redis.Publish(context.Background(), directoryChannel, payload).Err(); err != nil { + b.logger.Warn("发布端点目录失败", "user_id", userID, "error", err) + } +} + +func (b *Bridge) consumePubSub() { + channel := b.pubsub.Channel() + for { + select { + case <-b.ctx.Done(): + return + case message, ok := <-channel: + if !ok { + b.subscriptionReady.Store(false) + return + } + if message.Channel == directoryChannel { + var event directoryEvent + if json.Unmarshal([]byte(message.Payload), &event) == nil { + b.broadcastDirectory(event.UserID) + } + continue + } + var routed routedMessage + if json.Unmarshal([]byte(message.Payload), &routed) == nil { + b.handleRoutedMessage(routed) + } + } + } +} + +func (b *Bridge) broadcastDirectory(userID uuid.UUID) { + b.connsMu.RLock() + targets := make([]*clientConn, 0) + for _, client := range b.conns { + if client.userID == userID { + targets = append(targets, client) + } + } + b.connsMu.RUnlock() + for _, client := range targets { + b.sendDirectory(client) + } +} + +func (b *Bridge) watchPresence() { + interval := b.config.heartbeatInterval + if interval > 10*time.Second { + interval = 10 * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-b.ctx.Done(): + return + case <-ticker.C: + b.checkPresenceChanges() + } + } +} + +func (b *Bridge) checkPresenceChanges() { + b.connsMu.RLock() + users := make(map[uuid.UUID]struct{}) + for _, client := range b.conns { + users[client.userID] = struct{}{} + } + b.connsMu.RUnlock() + for userID := range users { + items, err := b.db.Endpoint.Query(). + Where(endpoint.UserID(userID), endpoint.StatusEQ(domain.EndpointStatusActive)). + Order(endpoint.ByMachineID()). + All(b.ctx) + if err != nil { + continue + } + online := make([]string, 0, len(items)) + failed := false + for _, item := range items { + exists, err := b.redis.Exists(b.ctx, presenceKey(userID, item.MachineID)).Result() + if err != nil { + failed = true + break + } + if exists == 1 { + online = append(online, item.MachineID.String()) + } + } + if failed { + continue + } + state := strings.Join(online, ",") + b.presenceMu.Lock() + previous, known := b.presenceState[userID] + b.presenceState[userID] = state + b.presenceMu.Unlock() + if known && previous != state { + b.publishDirectory(userID) + } + } +} + +func (b *Bridge) handleRoutedMessage(message routedMessage) { + target := b.localConnection(message.UserID, message.Target) + switch message.Kind { + case "route": + code := "" + if target == nil || target.generation != message.TargetGeneration { + code = "stale_route" + } else if !b.enqueueBusiness(target, message.Payload) { + code = "target_busy" + } + if code != "" { + payload, _ := json.Marshal(routedMessage{ + Kind: "route_error", + UserID: message.UserID, + Target: message.Source, + TargetGeneration: message.SourceGeneration, + ReplyTo: message.ReplyTo, + ErrorCode: code, + }) + _ = b.redis.Publish(b.ctx, instanceChannel(message.SourceInstance), payload).Err() + } + case "route_error": + if target != nil && target.generation == message.TargetGeneration { + b.enqueueError(target, message.ReplyTo, message.ErrorCode, true, time.Second) + } + case "close": + if target != nil && target.generation == message.TargetGeneration { + target.close(websocket.StatusCode(4001), "connection replaced") + } + case "revoke": + if target != nil && target.generation == message.TargetGeneration { + target.close(websocket.StatusCode(4002), "endpoint revoked") + } + } +} + +func (b *Bridge) localConnection(userID, machineID uuid.UUID) *clientConn { + b.connsMu.RLock() + defer b.connsMu.RUnlock() + return b.conns[localConnectionKey(userID, machineID)] +} + +func (b *Bridge) disconnectEndpoint(ctx context.Context, userID, machineID uuid.UUID, status websocket.StatusCode, reason string) { + raw, err := b.redis.Get(ctx, presenceKey(userID, machineID)).Bytes() + if err != nil { + return + } + var location presence + if json.Unmarshal(raw, &location) != nil { + return + } + if location.InstanceID == b.instanceID { + if client := b.localConnection(userID, machineID); client != nil && client.generation == location.Generation { + client.close(status, reason) + } + return + } + kind := "close" + if status == websocket.StatusCode(4002) { + kind = "revoke" + } + payload, _ := json.Marshal(routedMessage{ + Kind: kind, + UserID: userID, + Target: machineID, + TargetGeneration: location.Generation, + }) + _ = b.redis.Publish(ctx, instanceChannel(location.InstanceID), payload).Err() +} + +func (b *Bridge) acquireRegistrationLock(ctx context.Context, userID uuid.UUID) (func(), error) { + lockCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + token := uuid.NewString() + key := "endpoint:registration:" + userID.String() + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + err := b.redis.SetArgs(lockCtx, key, token, redis.SetArgs{ + Mode: string(redis.NX), + TTL: 5 * time.Second, + }).Err() + if err != nil && !errors.Is(err, redis.Nil) { + cancel() + return nil, errServiceUnavailable + } + if err == nil { + cancel() + return func() { + _, _ = releaseLockScript.Run(context.Background(), b.redis, []string{key}, token).Result() + }, nil + } + select { + case <-lockCtx.Done(): + cancel() + return nil, errServiceUnavailable + case <-ticker.C: + } + } +} + +func (c *clientConn) close(status websocket.StatusCode, reason string) { + c.closeOnce.Do(func() { + close(c.done) + _ = c.conn.Close(status, reason) + }) +} + +func localConnectionKey(userID, machineID uuid.UUID) string { + return userID.String() + ":" + machineID.String() +} + +func presenceKey(userID, machineID uuid.UUID) string { + return "endpoint:presence:" + localConnectionKey(userID, machineID) +} + +func instanceChannel(instanceID string) string { + return "endpoint:instance:" + instanceID +} + +var ( + errInvalidMessage = errors.New("invalid message") + errUnsupportedProtocol = errors.New("unsupported protocol") + errEndpointRevoked = errors.New("endpoint revoked") + errEndpointLimit = errors.New("endpoint limit exceeded") + errServiceUnavailable = errors.New("service unavailable") + errFrameTooLarge = errors.New("frame too large") + errUnsupportedData = errors.New("unsupported data") +) + +func (b *Bridge) writeHandshakeError(conn *websocket.Conn, err error) { + code := protocolErrorCode(err) + payload, _ := json.Marshal(map[string]any{ + "type": "error", + "error": map[string]any{ + "code": code, + "message": protocolErrorMessage(code), + "retryable": code == "service_unavailable", + }, + }) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + _ = conn.Write(ctx, websocket.MessageText, payload) + cancel() + _ = conn.Close(handshakeCloseStatus(err), protocolErrorMessage(code)) +} + +func handshakeCloseStatus(err error) websocket.StatusCode { + switch { + case errors.Is(err, errUnsupportedProtocol): + return websocket.StatusProtocolError + case errors.Is(err, errEndpointRevoked): + return websocket.StatusCode(4002) + case errors.Is(err, errFrameTooLarge): + return websocket.StatusMessageTooBig + case errors.Is(err, errUnsupportedData): + return websocket.StatusUnsupportedData + case errors.Is(err, errServiceUnavailable): + return websocket.StatusTryAgainLater + default: + return websocket.StatusPolicyViolation + } +} + +func (b *Bridge) closeForError(conn *websocket.Conn, err error) { + if errors.Is(err, errFrameTooLarge) { + _ = conn.Close(websocket.StatusMessageTooBig, "frame too large") + return + } + b.writeHandshakeError(conn, err) +} + +func protocolErrorCode(err error) string { + switch { + case errors.Is(err, errUnsupportedProtocol): + return "unsupported_protocol" + case errors.Is(err, errEndpointRevoked): + return "endpoint_revoked" + case errors.Is(err, errEndpointLimit): + return "endpoint_limit_exceeded" + case errors.Is(err, errServiceUnavailable): + return "service_unavailable" + case errors.Is(err, errFrameTooLarge): + return "payload_too_large" + default: + return "invalid_message" + } +} + +func protocolErrorMessage(code string) string { + switch code { + case "invalid_message": + return "消息格式无效" + case "unsupported_protocol": + return "协议版本不兼容" + case "endpoint_revoked": + return "端点已撤销" + case "endpoint_limit_exceeded": + return "端点数量已达上限" + case "target_unavailable": + return "目标端点不可用" + case "target_offline": + return "目标端点当前离线" + case "target_busy": + return "目标端点繁忙" + case "stale_route": + return "目标路由已变化" + case "rate_limited": + return "发送频率超过限制" + case "payload_too_large": + return "消息超过大小限制" + case "service_unavailable": + return "桥接服务暂不可用" + default: + return code + } +} + +var replacePresenceScript = redis.NewScript(` +local old = redis.call("GET", KEYS[1]) +redis.call("PSETEX", KEYS[1], ARGV[2], ARGV[1]) +return old +`) + +var deletePresenceScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +if not current then return 0 end +local decoded = cjson.decode(current) +if decoded.instance_id == ARGV[1] and decoded.generation == ARGV[2] then + return redis.call("DEL", KEYS[1]) +end +return 0 +`) + +var renewPresenceScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +if not current then return -1 end +local decoded = cjson.decode(current) +if decoded.instance_id == ARGV[1] and decoded.generation == ARGV[2] then + return redis.call("PEXPIRE", KEYS[1], ARGV[3]) +end +return 0 +`) + +var rateScript = redis.NewScript(` +local user_messages = redis.call("HINCRBY", KEYS[1], "messages", 1) +local user_bytes = redis.call("HINCRBY", KEYS[1], "bytes", ARGV[1]) +local client_messages = redis.call("HINCRBY", KEYS[2], "messages", 1) +local client_bytes = redis.call("HINCRBY", KEYS[2], "bytes", ARGV[1]) +redis.call("EXPIRE", KEYS[1], 2) +redis.call("EXPIRE", KEYS[2], 2) +return {user_messages, user_bytes, client_messages, client_bytes} +`) + +var releaseLockScript = redis.NewScript(` +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) +end +return 0 +`) diff --git a/backend/biz/endpoint/bridge_test.go b/backend/biz/endpoint/bridge_test.go new file mode 100644 index 000000000..4f458431c --- /dev/null +++ b/backend/biz/endpoint/bridge_test.go @@ -0,0 +1,624 @@ +package endpoint + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/GoYoko/web" + "github.com/alicebob/miniredis/v2" + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + _ "github.com/mattn/go-sqlite3" + "github.com/redis/go-redis/v9" + + "github.com/chaitin/MonkeyCode/backend/config" + "github.com/chaitin/MonkeyCode/backend/consts" + "github.com/chaitin/MonkeyCode/backend/db" + endpointdb "github.com/chaitin/MonkeyCode/backend/db/endpoint" + "github.com/chaitin/MonkeyCode/backend/db/enttest" + "github.com/chaitin/MonkeyCode/backend/domain" + "github.com/chaitin/MonkeyCode/backend/middleware" + "github.com/chaitin/MonkeyCode/backend/pkg/session" +) + +func TestBridgeDiscoversAndRoutesAcrossInstances(t *testing.T) { + ctx := context.Background() + dbClient := enttest.Open(t, "sqlite3", fmt.Sprintf("file:endpoint-bridge-%s?mode=memory&cache=shared&_fk=1", uuid.NewString())) + t.Cleanup(func() { _ = dbClient.Close() }) + redisServer := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + user := createBridgeUser(t, dbClient) + cookie := createBridgeSession(t, redisServer.Addr(), user) + serverA := newBridgeTestServer(t, dbClient, rdb, redisServer.Addr(), "instance-a") + serverB := newBridgeTestServer(t, dbClient, rdb, redisServer.Addr(), "instance-b") + + machineA := uuid.NewString() + machineB := uuid.NewString() + connA := connectBridge(t, serverA.URL, cookie, machineA, "桌面端", "macos") + defer connA.Close(websocket.StatusNormalClosure, "") + assertMessageType(t, readBridgeMessage(t, connA), "welcome") + assertDirectory(t, readBridgeMessage(t, connA), machineA) + + connB := connectBridge(t, serverB.URL, cookie, machineB, "移动端", "ios") + defer connB.Close(websocket.StatusNormalClosure, "") + assertMessageType(t, readBridgeMessage(t, connB), "welcome") + assertDirectory(t, readBridgeMessage(t, connB), machineA, machineB) + assertDirectory(t, readUntilDirectory(t, connA, machineA, machineB), machineA, machineB) + + requestID := uuid.NewString() + writeBridgeMessage(t, connA, map[string]any{ + "type": "request", + "message_id": requestID, + "target": machineB, + "method": "agent.continue", + "payload": map[string]any{"task_id": "task-1"}, + }) + request := readUntilType(t, connB, "request") + if request["source"] != machineA || request["target"] != machineB || request["message_id"] != requestID { + t.Fatalf("转发请求身份不正确: %#v", request) + } + if _, ok := request["routed_at"].(float64); !ok { + t.Fatalf("转发请求缺少 routed_at: %#v", request) + } + + responseID := uuid.NewString() + writeBridgeMessage(t, connB, map[string]any{ + "type": "response", + "message_id": responseID, + "target": machineA, + "reply_to": requestID, + "payload": map[string]any{"accepted": true}, + }) + response := readUntilType(t, connA, "response") + if response["source"] != machineB || response["reply_to"] != requestID || response["message_id"] != responseID { + t.Fatalf("转发响应关联不正确: %#v", response) + } + + offlineID := uuid.NewString() + writeBridgeMessage(t, connA, map[string]any{ + "type": "event", + "message_id": uuid.NewString(), + "target": offlineID, + "method": "agent.notice", + "payload": map[string]any{}, + }) + protocolError := readUntilType(t, connA, "error") + errorBody, _ := protocolError["error"].(map[string]any) + if errorBody["code"] != "target_unavailable" { + t.Fatalf("未知目标错误 = %#v", protocolError) + } + + if got := rdb.TTL(ctx, presenceKey(user.ID, uuid.MustParse(machineA))).Val(); got <= 0 { + t.Fatalf("端点在线租约未建立: %v", got) + } +} + +func TestBridgeIsolatesUsersAndManagesEndpointLifecycle(t *testing.T) { + dbClient := enttest.Open(t, "sqlite3", fmt.Sprintf("file:endpoint-management-%s?mode=memory&cache=shared&_fk=1", uuid.NewString())) + t.Cleanup(func() { _ = dbClient.Close() }) + redisServer := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + owner := createBridgeUser(t, dbClient) + other := createBridgeUser(t, dbClient) + ownerCookie := createBridgeSession(t, redisServer.Addr(), owner) + otherCookie := createBridgeSession(t, redisServer.Addr(), other) + serverA := newBridgeTestServer(t, dbClient, rdb, redisServer.Addr(), "management-a") + serverB := newBridgeTestServer(t, dbClient, rdb, redisServer.Addr(), "management-b") + + machineA := uuid.NewString() + machineB := uuid.NewString() + machineOther := uuid.NewString() + connA := connectBridge(t, serverA.URL, ownerCookie, machineA, "办公电脑", "macos") + defer connA.Close(websocket.StatusNormalClosure, "") + assertMessageType(t, readBridgeMessage(t, connA), "welcome") + assertDirectory(t, readBridgeMessage(t, connA), machineA) + connB := connectBridge(t, serverB.URL, ownerCookie, machineB, "手机", "ios") + assertMessageType(t, readBridgeMessage(t, connB), "welcome") + assertDirectory(t, readBridgeMessage(t, connB), machineA, machineB) + readUntilDirectory(t, connA, machineA, machineB) + + connOther := connectBridge(t, serverA.URL, otherCookie, machineOther, "其他用户", "android") + defer connOther.Close(websocket.StatusNormalClosure, "") + assertMessageType(t, readBridgeMessage(t, connOther), "welcome") + assertDirectory(t, readBridgeMessage(t, connOther), machineOther) + + messageID := uuid.NewString() + writeBridgeMessage(t, connA, map[string]any{ + "type": "event", + "message_id": messageID, + "target": machineOther, + "method": "agent.notice", + "payload": map[string]any{}, + }) + protocolError := readUntilType(t, connA, "error") + errorBody, _ := protocolError["error"].(map[string]any) + if errorBody["code"] != "target_unavailable" { + t.Fatalf("跨用户路由错误 = %#v", protocolError) + } + + list := requestBridgeAPI(t, http.MethodGet, serverA.URL+"/api/v1/endpoints", ownerCookie, nil) + items, _ := list["data"].([]any) + if len(items) != 2 { + t.Fatalf("当前用户端点数量 = %d, want 2: %#v", len(items), list) + } + + alias := "随身手机" + updated := requestBridgeAPI( + t, + http.MethodPatch, + serverA.URL+"/api/v1/endpoints/"+machineB, + ownerCookie, + map[string]any{"alias": alias}, + ) + data, _ := updated["data"].(map[string]any) + if data["display_name"] != alias { + t.Fatalf("端点别名未更新: %#v", updated) + } + + requestBridgeAPI(t, http.MethodPost, serverA.URL+"/api/v1/endpoints/"+machineB+"/revoke", ownerCookie, nil) + if status := waitForBridgeClose(t, connB); status != websocket.StatusCode(4002) { + t.Fatalf("撤销关闭码 = %d, want 4002", status) + } + readUntilDirectory(t, connA, machineA) + waitForEndpointOffline(t, rdb, owner.ID, uuid.MustParse(machineB)) + + requestBridgeAPI(t, http.MethodPost, serverA.URL+"/api/v1/endpoints/"+machineB+"/restore", ownerCookie, nil) + readUntilDirectory(t, connA, machineA, machineB) + restored := connectBridge(t, serverB.URL, ownerCookie, machineB, "手机", "ios") + assertMessageType(t, readBridgeMessage(t, restored), "welcome") + assertDirectory(t, readBridgeMessage(t, restored), machineA, machineB) + replacement := connectBridge(t, serverA.URL, ownerCookie, machineB, "手机", "ios") + defer replacement.Close(websocket.StatusNormalClosure, "") + assertMessageType(t, readBridgeMessage(t, replacement), "welcome") + assertDirectory(t, readBridgeMessage(t, replacement), machineA, machineB) + if status := waitForBridgeClose(t, restored); status != websocket.StatusCode(4001) { + t.Fatalf("连接替换关闭码 = %d, want 4001", status) + } +} + +func TestRestoreRemainsIdempotentWhenEndpointIsRestoredWhileWaitingForLock(t *testing.T) { + ctx := context.Background() + dbClient := enttest.Open(t, "sqlite3", fmt.Sprintf("file:endpoint-restore-%s?mode=memory&cache=shared&_fk=1", uuid.NewString())) + t.Cleanup(func() { _ = dbClient.Close() }) + redisServer := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + user := createBridgeUser(t, dbClient) + cookie := createBridgeSession(t, redisServer.Addr(), user) + server := newBridgeTestServerWithConfig(t, dbClient, rdb, redisServer.Addr(), "restore", func(cfg *config.Config) { + cfg.EndpointBridge.MaxEndpoints = 1 + }) + machineID := uuid.New() + item, err := dbClient.Endpoint.Create(). + SetUserID(user.ID). + SetMachineID(machineID). + SetDeviceName("手机"). + SetPlatform(endpointdb.PlatformIos). + SetOsVersion("1.0"). + SetArch("arm64"). + SetClientVersion("test"). + SetStatus(endpointdb.StatusRevoked). + Save(ctx) + if err != nil { + t.Fatal(err) + } + + lockKey := "endpoint:registration:" + user.ID.String() + if err := rdb.Set(ctx, lockKey, "other", 5*time.Second).Err(); err != nil { + t.Fatal(err) + } + commandCount := redisServer.CommandCount() + result := make(chan error, 1) + go func() { + req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/endpoints/"+machineID.String()+"/restore", nil) + if err != nil { + result <- err + return + } + req.AddCookie(cookie) + response, err := http.DefaultClient.Do(req) + if err != nil { + result <- err + return + } + defer response.Body.Close() + var payload map[string]any + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + result <- err + return + } + if response.StatusCode != http.StatusOK || payload["code"] != float64(0) { + result <- fmt.Errorf("恢复端点失败: status=%d payload=%v", response.StatusCode, payload) + return + } + result <- nil + }() + + deadline := time.Now().Add(time.Second) + for redisServer.CommandCount() < commandCount+3 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if redisServer.CommandCount() < commandCount+3 { + t.Fatal("恢复请求未等待注册锁") + } + for { + _, err = item.Update().SetStatus(endpointdb.StatusActive).Save(ctx) + if err == nil { + break + } + if !strings.Contains(err.Error(), "database table is locked") || time.Now().After(deadline) { + t.Fatal(err) + } + time.Sleep(time.Millisecond) + } + if err := rdb.Del(ctx, lockKey).Err(); err != nil { + t.Fatal(err) + } + if err := <-result; err != nil { + t.Fatal(err) + } +} + +func TestBridgeRejectsUntrustedOriginAndInvalidEnvelope(t *testing.T) { + dbClient := enttest.Open(t, "sqlite3", fmt.Sprintf("file:endpoint-security-%s?mode=memory&cache=shared&_fk=1", uuid.NewString())) + t.Cleanup(func() { _ = dbClient.Close() }) + redisServer := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + user := createBridgeUser(t, dbClient) + cookie := createBridgeSession(t, redisServer.Addr(), user) + server := newBridgeTestServer(t, dbClient, rdb, redisServer.Addr(), "security") + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + header := http.Header{} + header.Set("Cookie", cookie.String()) + header.Set("Origin", "https://evil.example.com") + conn, response, err := websocket.Dial( + ctx, + "ws"+strings.TrimPrefix(server.URL, "http")+"/api/v1/endpoints/connect", + &websocket.DialOptions{HTTPHeader: header}, + ) + if conn != nil { + _ = conn.CloseNow() + } + if err == nil || response == nil || response.StatusCode != http.StatusForbidden { + t.Fatalf("不可信 Origin 应被拒绝: response=%v err=%v", response, err) + } + + machineID := uuid.NewString() + valid := connectBridge(t, server.URL, cookie, machineID, "桌面端", "macos") + defer valid.Close(websocket.StatusNormalClosure, "") + assertMessageType(t, readBridgeMessage(t, valid), "welcome") + assertDirectory(t, readBridgeMessage(t, valid), machineID) + writeBridgeMessage(t, valid, map[string]any{ + "type": "event", + "message_id": uuid.NewString(), + "target": machineID, + "method": "agent.notice", + "source": machineID, + "payload": map[string]any{}, + }) + protocolError := readUntilType(t, valid, "error") + errorBody, _ := protocolError["error"].(map[string]any) + if errorBody["code"] != "invalid_message" { + t.Fatalf("伪造来源错误 = %#v", protocolError) + } + + messageID := uuid.NewString() + writeBridgeMessage(t, valid, map[string]any{ + "type": "request", + "message_id": messageID, + "target": machineID, + "method": "Invalid Method", + "payload": map[string]any{}, + }) + protocolError = readUntilType(t, valid, "error") + if protocolError["reply_to"] != messageID { + t.Fatalf("可识别消息的协议错误未关联原消息: %#v", protocolError) + } +} + +func TestHandshakeCloseStatusAllowsServiceUnavailableRetry(t *testing.T) { + if got := handshakeCloseStatus(errServiceUnavailable); got != websocket.StatusTryAgainLater { + t.Fatalf("服务不可用关闭码 = %d, want %d", got, websocket.StatusTryAgainLater) + } + if got := handshakeCloseStatus(errUnsupportedData); got != websocket.StatusUnsupportedData { + t.Fatalf("二进制握手关闭码 = %d, want %d", got, websocket.StatusUnsupportedData) + } +} + +func TestDevelopmentPlainWebSocketOnlyAllowsLoopback(t *testing.T) { + bridge := &Bridge{config: bridgeConfig{debug: true}} + external := httptest.NewRequest(http.MethodGet, "http://example.com/api/v1/endpoints/connect", nil) + if err := bridge.validateUpgrade(external); err == nil { + t.Fatal("开发环境不应允许非本机明文 WebSocket") + } + loopback := httptest.NewRequest(http.MethodGet, "http://127.0.0.1/api/v1/endpoints/connect", nil) + if err := bridge.validateUpgrade(loopback); err != nil { + t.Fatalf("开发环境本机明文 WebSocket 被拒绝: %v", err) + } +} + +func newBridgeTestServer(t *testing.T, dbClient *db.Client, rdb *redis.Client, redisAddr, instanceID string) *httptest.Server { + t.Helper() + return newBridgeTestServerWithConfig(t, dbClient, rdb, redisAddr, instanceID, nil) +} + +func newBridgeTestServerWithConfig( + t *testing.T, + dbClient *db.Client, + rdb *redis.Client, + redisAddr string, + instanceID string, + configure func(*config.Config), +) *httptest.Server { + t.Helper() + host, portText, err := net.SplitHostPort(redisAddr) + if err != nil { + t.Fatal(err) + } + var port int + if _, err := fmt.Sscanf(portText, "%d", &port); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Debug = true + cfg.Redis.Host = host + cfg.Redis.Port = port + cfg.Session.ExpireDay = 1 + cfg.EndpointBridge.InstanceID = instanceID + cfg.EndpointBridge.AllowedOrigins = []string{"https://app.example.com"} + if configure != nil { + configure(cfg) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + sess := session.New(cfg) + auth := middleware.NewAuthMiddleware(sess, nil, logger) + w := web.New() + e := w.Echo() + bridge := newBridge(dbClient, rdb, cfg, logger) + bridge.Register(w, auth) + t.Cleanup(func() { bridge.Close() }) + server := httptest.NewServer(e) + t.Cleanup(server.Close) + return server +} + +func createBridgeUser(t *testing.T, client *db.Client) *domain.User { + t.Helper() + user := &domain.User{ + ID: uuid.New(), + Name: "端点测试用户", + Role: consts.UserRoleIndividual, + Status: consts.UserStatusActive, + } + if _, err := client.User.Create(). + SetID(user.ID). + SetName(user.Name). + SetRole(user.Role). + SetStatus(user.Status). + Save(context.Background()); err != nil { + t.Fatal(err) + } + return user +} + +func createBridgeSession(t *testing.T, redisAddr string, user *domain.User) *http.Cookie { + t.Helper() + host, portText, err := net.SplitHostPort(redisAddr) + if err != nil { + t.Fatal(err) + } + var port int + if _, err := fmt.Sscanf(portText, "%d", &port); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Redis.Host = host + cfg.Redis.Port = port + cfg.Session.ExpireDay = 1 + sess := session.New(cfg) + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + if _, err := sess.Save(c, consts.MonkeyCodeAISession, user.ID, user); err != nil { + t.Fatal(err) + } + cookies := rec.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("登录 Cookie 数量 = %d", len(cookies)) + } + return cookies[0] +} + +func connectBridge(t *testing.T, baseURL string, cookie *http.Cookie, machineID, deviceName, platform string) *websocket.Conn { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + header := http.Header{} + header.Set("Cookie", cookie.String()) + conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(baseURL, "http")+"/api/v1/endpoints/connect", &websocket.DialOptions{HTTPHeader: header}) + if err != nil { + t.Fatal(err) + } + writeBridgeMessage(t, conn, map[string]any{ + "type": "hello", + "protocol_versions": []int{1}, + "machine_id": machineID, + "profile": map[string]any{ + "device_name": deviceName, + "platform": platform, + "os_version": "1.0", + "arch": "arm64", + "client_version": "test", + }, + }) + return conn +} + +func writeBridgeMessage(t *testing.T, conn *websocket.Conn, value any) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := wsjson.Write(ctx, conn, value); err != nil { + t.Fatal(err) + } +} + +func readBridgeMessage(t *testing.T, conn *websocket.Conn) map[string]any { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + var message map[string]any + if err := wsjson.Read(ctx, conn, &message); err != nil { + t.Fatal(err) + } + return message +} + +func requestBridgeAPI(t *testing.T, method, url string, cookie *http.Cookie, body any) map[string]any { + t.Helper() + var raw []byte + if body != nil { + var err error + raw, err = json.Marshal(body) + if err != nil { + t.Fatal(err) + } + } + req, err := http.NewRequest(method, url, bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + req.AddCookie(cookie) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var payload map[string]any + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || payload["code"] != float64(0) { + t.Fatalf("%s %s 失败: status=%d payload=%#v", method, url, response.StatusCode, payload) + } + return payload +} + +func waitForBridgeClose(t *testing.T, conn *websocket.Conn) websocket.StatusCode { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + for { + _, _, err := conn.Read(ctx) + if err == nil { + continue + } + return websocket.CloseStatus(err) + } +} + +func waitForEndpointOffline(t *testing.T, rdb *redis.Client, userID, machineID uuid.UUID) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if rdb.Exists(context.Background(), presenceKey(userID, machineID)).Val() == 0 { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("端点在线租约未及时清理: %s", machineID) +} + +func readUntilType(t *testing.T, conn *websocket.Conn, messageType string) map[string]any { + t.Helper() + for range 8 { + message := readBridgeMessage(t, conn) + if message["type"] == messageType { + return message + } + } + t.Fatalf("未收到消息类型 %s", messageType) + return nil +} + +func readUntilDirectory(t *testing.T, conn *websocket.Conn, machines ...string) map[string]any { + t.Helper() + for range 8 { + message := readBridgeMessage(t, conn) + if message["type"] != "directory.snapshot" { + continue + } + items, _ := message["endpoints"].([]any) + found := make(map[string]bool, len(items)) + for _, item := range items { + endpoint, _ := item.(map[string]any) + found[fmt.Sprint(endpoint["machine_id"])] = true + } + if len(found) != len(machines) { + continue + } + matches := true + for _, machineID := range machines { + matches = matches && found[machineID] + } + if matches { + return message + } + } + t.Fatalf("未收到期望的完整端点目录: %v", machines) + return nil +} + +func assertMessageType(t *testing.T, message map[string]any, want string) { + t.Helper() + if message["type"] != want { + raw, _ := json.Marshal(message) + t.Fatalf("消息类型 = %v, want %s, message=%s", message["type"], want, raw) + } +} + +func assertDirectory(t *testing.T, message map[string]any, machines ...string) { + t.Helper() + assertMessageType(t, message, "directory.snapshot") + items, ok := message["endpoints"].([]any) + if !ok { + t.Fatalf("目录格式错误: %#v", message) + } + got := make(map[string]bool, len(items)) + for _, item := range items { + endpoint, _ := item.(map[string]any) + got[fmt.Sprint(endpoint["machine_id"])] = true + } + for _, machineID := range machines { + if !got[machineID] { + t.Fatalf("目录缺少 %s: %#v", machineID, message) + } + } + if len(got) != len(machines) { + t.Fatalf("目录端点数量 = %d, want %d: %#v", len(got), len(machines), message) + } +} diff --git a/backend/biz/endpoint/register.go b/backend/biz/endpoint/register.go new file mode 100644 index 000000000..ebbfbba1f --- /dev/null +++ b/backend/biz/endpoint/register.go @@ -0,0 +1,33 @@ +package endpoint + +import ( + "log/slog" + + "github.com/GoYoko/web" + "github.com/redis/go-redis/v9" + "github.com/samber/do" + + "github.com/chaitin/MonkeyCode/backend/config" + "github.com/chaitin/MonkeyCode/backend/db" + "github.com/chaitin/MonkeyCode/backend/middleware" +) + +func ProvideEndpoint(i *do.Injector) { + do.Provide(i, func(i *do.Injector) (*Bridge, error) { + bridge := newBridge( + do.MustInvoke[*db.Client](i), + do.MustInvoke[*redis.Client](i), + do.MustInvoke[*config.Config](i), + do.MustInvoke[*slog.Logger](i), + ) + bridge.Register( + do.MustInvoke[*web.Web](i), + do.MustInvoke[*middleware.AuthMiddleware](i), + ) + return bridge, nil + }) +} + +func InvokeEndpoint(i *do.Injector) { + do.MustInvoke[*Bridge](i) +} diff --git a/backend/biz/register.go b/backend/biz/register.go index fe16ba394..b9ad3158f 100644 --- a/backend/biz/register.go +++ b/backend/biz/register.go @@ -7,6 +7,7 @@ import ( "github.com/samber/do" "github.com/chaitin/MonkeyCode/backend/biz/agentresource" + "github.com/chaitin/MonkeyCode/backend/biz/endpoint" "github.com/chaitin/MonkeyCode/backend/biz/file" "github.com/chaitin/MonkeyCode/backend/biz/git" "github.com/chaitin/MonkeyCode/backend/biz/host" @@ -39,6 +40,7 @@ func RegisterAll(i *do.Injector) error { setting.ProvideSetting(i) team.ProvideTeam(i) host.ProvideHost(i) + endpoint.ProvideEndpoint(i) agentresource.ProvideAgentResource(i) task.ProvideTask(i) git.ProvideGit(i) @@ -58,6 +60,7 @@ func InvokeAll(i *do.Injector) { setting.InvokeSetting(i) team.InvokeTeam(i) host.InvokeHost(i) + endpoint.InvokeEndpoint(i) task.InvokeTask(i) git.InvokeGit(i) project.InvokeProject(i) diff --git a/backend/config/config.go b/backend/config/config.go index 2c4118804..10d02782d 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -61,9 +61,10 @@ type Config struct { // Aliyun mirrors mcai-backend's aliyun.public_oss block so the agent- // resources Resolver can fall back to that bucket when ObjectStorage is // disabled (the same OSS that admin-new writes assets into). Optional. - Aliyun AliyunConfig `mapstructure:"aliyun"` - StaticFiles StaticFilesConfig `mapstructure:"static_files"` - HostInstaller HostInstaller `mapstructure:"host_installer"` + Aliyun AliyunConfig `mapstructure:"aliyun"` + StaticFiles StaticFilesConfig `mapstructure:"static_files"` + HostInstaller HostInstaller `mapstructure:"host_installer"` + EndpointBridge EndpointBridge `mapstructure:"endpoint_bridge"` // Context7 API 配置 Context7ApiKey string `mapstructure:"context7_api_key"` @@ -98,6 +99,20 @@ type ReviewAgent struct { Image string `mapstructure:"image"` } +type EndpointBridge struct { + InstanceID string `mapstructure:"instance_id"` + AllowedOrigins []string `mapstructure:"allowed_origins"` + MaxEndpoints int `mapstructure:"max_endpoints"` + MaxFrameBytes int64 `mapstructure:"max_frame_bytes"` + QueueMessages int `mapstructure:"queue_messages"` + QueueBytes int64 `mapstructure:"queue_bytes"` + MessageRate int `mapstructure:"message_rate"` + ByteRate int64 `mapstructure:"byte_rate"` + HeartbeatInterval string `mapstructure:"heartbeat_interval"` + HeartbeatTimeout string `mapstructure:"heartbeat_timeout"` + PresenceTTL string `mapstructure:"presence_ttl"` +} + type OAuthLoginConfig struct { Google OAuthLoginProviderConfig `mapstructure:"google"` Github OAuthLoginProviderConfig `mapstructure:"github"` @@ -392,6 +407,17 @@ func Init(dir string) (*Config, error) { v.SetDefault("static_files.route_prefix", "/static") v.SetDefault("host_installer.mode", "online") v.SetDefault("host_installer.bundle_path", "installer/{{.arch}}/host.tgz") + v.SetDefault("endpoint_bridge.instance_id", "") + v.SetDefault("endpoint_bridge.allowed_origins", []string{}) + v.SetDefault("endpoint_bridge.max_endpoints", 20) + v.SetDefault("endpoint_bridge.max_frame_bytes", 256<<10) + v.SetDefault("endpoint_bridge.queue_messages", 128) + v.SetDefault("endpoint_bridge.queue_bytes", 4<<20) + v.SetDefault("endpoint_bridge.message_rate", 100) + v.SetDefault("endpoint_bridge.byte_rate", 4<<20) + v.SetDefault("endpoint_bridge.heartbeat_interval", "30s") + v.SetDefault("endpoint_bridge.heartbeat_timeout", "10s") + v.SetDefault("endpoint_bridge.presence_ttl", "60s") v.SetDefault("llm_proxy.base_url", "") v.SetDefault("wechat.open.app_id", "") v.SetDefault("wechat.open.app_secret", "") @@ -419,7 +445,7 @@ func Init(dir string) (*Config, error) { v.SetConfigType("yaml") v.AddConfigPath(dir) v.SetConfigName("config") - v.ReadInConfig() + _ = v.ReadInConfig() if err := normalizeWechatMPTemplates(v); err != nil { return nil, err diff --git a/backend/db/client.go b/backend/db/client.go index 77c7e4d36..ad9a645c9 100644 --- a/backend/db/client.go +++ b/backend/db/client.go @@ -27,6 +27,7 @@ import ( "github.com/chaitin/MonkeyCode/backend/db/agentskillversion" "github.com/chaitin/MonkeyCode/backend/db/agentsyncjob" "github.com/chaitin/MonkeyCode/backend/db/audit" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" "github.com/chaitin/MonkeyCode/backend/db/gitbot" "github.com/chaitin/MonkeyCode/backend/db/gitbottask" "github.com/chaitin/MonkeyCode/backend/db/gitbotuser" @@ -101,6 +102,8 @@ type Client struct { AgentSyncJob *AgentSyncJobClient // Audit is the client for interacting with the Audit builders. Audit *AuditClient + // Endpoint is the client for interacting with the Endpoint builders. + Endpoint *EndpointClient // GitBot is the client for interacting with the GitBot builders. GitBot *GitBotClient // GitBotTask is the client for interacting with the GitBotTask builders. @@ -209,6 +212,7 @@ func (c *Client) init() { c.AgentSkillVersion = NewAgentSkillVersionClient(c.config) c.AgentSyncJob = NewAgentSyncJobClient(c.config) c.Audit = NewAuditClient(c.config) + c.Endpoint = NewEndpointClient(c.config) c.GitBot = NewGitBotClient(c.config) c.GitBotTask = NewGitBotTaskClient(c.config) c.GitBotUser = NewGitBotUserClient(c.config) @@ -355,6 +359,7 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) { AgentSkillVersion: NewAgentSkillVersionClient(cfg), AgentSyncJob: NewAgentSyncJobClient(cfg), Audit: NewAuditClient(cfg), + Endpoint: NewEndpointClient(cfg), GitBot: NewGitBotClient(cfg), GitBotTask: NewGitBotTaskClient(cfg), GitBotUser: NewGitBotUserClient(cfg), @@ -428,6 +433,7 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) AgentSkillVersion: NewAgentSkillVersionClient(cfg), AgentSyncJob: NewAgentSyncJobClient(cfg), Audit: NewAuditClient(cfg), + Endpoint: NewEndpointClient(cfg), GitBot: NewGitBotClient(cfg), GitBotTask: NewGitBotTaskClient(cfg), GitBotUser: NewGitBotUserClient(cfg), @@ -502,16 +508,17 @@ func (c *Client) Use(hooks ...Hook) { for _, n := range []interface{ Use(...Hook) }{ c.AgentPlugin, c.AgentPluginRepo, c.AgentPluginVersion, c.AgentRule, c.AgentRuleVersion, c.AgentSkill, c.AgentSkillGroupBinding, c.AgentSkillRepo, - c.AgentSkillVersion, c.AgentSyncJob, c.Audit, c.GitBot, c.GitBotTask, - c.GitBotUser, c.GitIdentity, c.GitTask, c.Host, c.Image, c.MCPTool, - c.MCPToolCall, c.MCPUpstream, c.MCPUserToolSetting, c.Model, c.ModelApiKey, - c.ModelPricing, c.NotifyChannel, c.NotifySendLog, c.NotifySubscription, - c.Project, c.ProjectCollaborator, c.ProjectGitBot, c.ProjectIssue, - c.ProjectIssueComment, c.ProjectTask, c.Task, c.TaskModelSwitch, - c.TaskUsageStat, c.TaskVirtualMachine, c.Team, c.TeamExtensionImageArchive, - c.TeamGroup, c.TeamGroupHost, c.TeamGroupImage, c.TeamGroupMCPUpstream, - c.TeamGroupMember, c.TeamGroupModel, c.TeamHost, c.TeamImage, c.TeamMember, - c.TeamModel, c.TeamOIDCConfig, c.User, c.UserIdentity, c.VirtualMachine, + c.AgentSkillVersion, c.AgentSyncJob, c.Audit, c.Endpoint, c.GitBot, + c.GitBotTask, c.GitBotUser, c.GitIdentity, c.GitTask, c.Host, c.Image, + c.MCPTool, c.MCPToolCall, c.MCPUpstream, c.MCPUserToolSetting, c.Model, + c.ModelApiKey, c.ModelPricing, c.NotifyChannel, c.NotifySendLog, + c.NotifySubscription, c.Project, c.ProjectCollaborator, c.ProjectGitBot, + c.ProjectIssue, c.ProjectIssueComment, c.ProjectTask, c.Task, + c.TaskModelSwitch, c.TaskUsageStat, c.TaskVirtualMachine, c.Team, + c.TeamExtensionImageArchive, c.TeamGroup, c.TeamGroupHost, c.TeamGroupImage, + c.TeamGroupMCPUpstream, c.TeamGroupMember, c.TeamGroupModel, c.TeamHost, + c.TeamImage, c.TeamMember, c.TeamModel, c.TeamOIDCConfig, c.User, + c.UserIdentity, c.VirtualMachine, } { n.Use(hooks...) } @@ -523,16 +530,17 @@ func (c *Client) Intercept(interceptors ...Interceptor) { for _, n := range []interface{ Intercept(...Interceptor) }{ c.AgentPlugin, c.AgentPluginRepo, c.AgentPluginVersion, c.AgentRule, c.AgentRuleVersion, c.AgentSkill, c.AgentSkillGroupBinding, c.AgentSkillRepo, - c.AgentSkillVersion, c.AgentSyncJob, c.Audit, c.GitBot, c.GitBotTask, - c.GitBotUser, c.GitIdentity, c.GitTask, c.Host, c.Image, c.MCPTool, - c.MCPToolCall, c.MCPUpstream, c.MCPUserToolSetting, c.Model, c.ModelApiKey, - c.ModelPricing, c.NotifyChannel, c.NotifySendLog, c.NotifySubscription, - c.Project, c.ProjectCollaborator, c.ProjectGitBot, c.ProjectIssue, - c.ProjectIssueComment, c.ProjectTask, c.Task, c.TaskModelSwitch, - c.TaskUsageStat, c.TaskVirtualMachine, c.Team, c.TeamExtensionImageArchive, - c.TeamGroup, c.TeamGroupHost, c.TeamGroupImage, c.TeamGroupMCPUpstream, - c.TeamGroupMember, c.TeamGroupModel, c.TeamHost, c.TeamImage, c.TeamMember, - c.TeamModel, c.TeamOIDCConfig, c.User, c.UserIdentity, c.VirtualMachine, + c.AgentSkillVersion, c.AgentSyncJob, c.Audit, c.Endpoint, c.GitBot, + c.GitBotTask, c.GitBotUser, c.GitIdentity, c.GitTask, c.Host, c.Image, + c.MCPTool, c.MCPToolCall, c.MCPUpstream, c.MCPUserToolSetting, c.Model, + c.ModelApiKey, c.ModelPricing, c.NotifyChannel, c.NotifySendLog, + c.NotifySubscription, c.Project, c.ProjectCollaborator, c.ProjectGitBot, + c.ProjectIssue, c.ProjectIssueComment, c.ProjectTask, c.Task, + c.TaskModelSwitch, c.TaskUsageStat, c.TaskVirtualMachine, c.Team, + c.TeamExtensionImageArchive, c.TeamGroup, c.TeamGroupHost, c.TeamGroupImage, + c.TeamGroupMCPUpstream, c.TeamGroupMember, c.TeamGroupModel, c.TeamHost, + c.TeamImage, c.TeamMember, c.TeamModel, c.TeamOIDCConfig, c.User, + c.UserIdentity, c.VirtualMachine, } { n.Intercept(interceptors...) } @@ -563,6 +571,8 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) { return c.AgentSyncJob.mutate(ctx, m) case *AuditMutation: return c.Audit.mutate(ctx, m) + case *EndpointMutation: + return c.Endpoint.mutate(ctx, m) case *GitBotMutation: return c.GitBot.mutate(ctx, m) case *GitBotTaskMutation: @@ -2325,6 +2335,155 @@ func (c *AuditClient) mutate(ctx context.Context, m *AuditMutation) (Value, erro } } +// EndpointClient is a client for the Endpoint schema. +type EndpointClient struct { + config +} + +// NewEndpointClient returns a client for the Endpoint from the given config. +func NewEndpointClient(c config) *EndpointClient { + return &EndpointClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `endpoint.Hooks(f(g(h())))`. +func (c *EndpointClient) Use(hooks ...Hook) { + c.hooks.Endpoint = append(c.hooks.Endpoint, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `endpoint.Intercept(f(g(h())))`. +func (c *EndpointClient) Intercept(interceptors ...Interceptor) { + c.inters.Endpoint = append(c.inters.Endpoint, interceptors...) +} + +// Create returns a builder for creating a Endpoint entity. +func (c *EndpointClient) Create() *EndpointCreate { + mutation := newEndpointMutation(c.config, OpCreate) + return &EndpointCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of Endpoint entities. +func (c *EndpointClient) CreateBulk(builders ...*EndpointCreate) *EndpointCreateBulk { + return &EndpointCreateBulk{config: c.config, builders: builders} +} + +// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates +// a builder and applies setFunc on it. +func (c *EndpointClient) MapCreateBulk(slice any, setFunc func(*EndpointCreate, int)) *EndpointCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &EndpointCreateBulk{err: fmt.Errorf("calling to EndpointClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*EndpointCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &EndpointCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for Endpoint. +func (c *EndpointClient) Update() *EndpointUpdate { + mutation := newEndpointMutation(c.config, OpUpdate) + return &EndpointUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *EndpointClient) UpdateOne(_m *Endpoint) *EndpointUpdateOne { + mutation := newEndpointMutation(c.config, OpUpdateOne, withEndpoint(_m)) + return &EndpointUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *EndpointClient) UpdateOneID(id uuid.UUID) *EndpointUpdateOne { + mutation := newEndpointMutation(c.config, OpUpdateOne, withEndpointID(id)) + return &EndpointUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for Endpoint. +func (c *EndpointClient) Delete() *EndpointDelete { + mutation := newEndpointMutation(c.config, OpDelete) + return &EndpointDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *EndpointClient) DeleteOne(_m *Endpoint) *EndpointDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *EndpointClient) DeleteOneID(id uuid.UUID) *EndpointDeleteOne { + builder := c.Delete().Where(endpoint.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &EndpointDeleteOne{builder} +} + +// Query returns a query builder for Endpoint. +func (c *EndpointClient) Query() *EndpointQuery { + return &EndpointQuery{ + config: c.config, + ctx: &QueryContext{Type: TypeEndpoint}, + inters: c.Interceptors(), + } +} + +// Get returns a Endpoint entity by its id. +func (c *EndpointClient) Get(ctx context.Context, id uuid.UUID) (*Endpoint, error) { + return c.Query().Where(endpoint.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *EndpointClient) GetX(ctx context.Context, id uuid.UUID) *Endpoint { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// QueryUser queries the user edge of a Endpoint. +func (c *EndpointClient) QueryUser(_m *Endpoint) *UserQuery { + query := (&UserClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(endpoint.Table, endpoint.FieldID, id), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, endpoint.UserTable, endpoint.UserColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// Hooks returns the client hooks. +func (c *EndpointClient) Hooks() []Hook { + return c.hooks.Endpoint +} + +// Interceptors returns the client interceptors. +func (c *EndpointClient) Interceptors() []Interceptor { + return c.inters.Endpoint +} + +func (c *EndpointClient) mutate(ctx context.Context, m *EndpointMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&EndpointCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&EndpointUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&EndpointUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&EndpointDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("db: unknown Endpoint mutation op: %q", m.Op()) + } +} + // GitBotClient is a client for the GitBot schema. type GitBotClient struct { config @@ -10203,6 +10362,22 @@ func (c *UserClient) QueryMcpUpstreams(_m *User) *MCPUpstreamQuery { return query } +// QueryEndpoints queries the endpoints edge of a User. +func (c *UserClient) QueryEndpoints(_m *User) *EndpointQuery { + query := (&EndpointClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(user.Table, user.FieldID, id), + sqlgraph.To(endpoint.Table, endpoint.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, user.EndpointsTable, user.EndpointsColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + // QueryTeamMembers queries the team_members edge of a User. func (c *UserClient) QueryTeamMembers(_m *User) *TeamMemberQuery { query := (&TeamMemberClient{config: c.config}).Query() @@ -10665,27 +10840,27 @@ type ( hooks struct { AgentPlugin, AgentPluginRepo, AgentPluginVersion, AgentRule, AgentRuleVersion, AgentSkill, AgentSkillGroupBinding, AgentSkillRepo, AgentSkillVersion, - AgentSyncJob, Audit, GitBot, GitBotTask, GitBotUser, GitIdentity, GitTask, - Host, Image, MCPTool, MCPToolCall, MCPUpstream, MCPUserToolSetting, Model, - ModelApiKey, ModelPricing, NotifyChannel, NotifySendLog, NotifySubscription, - Project, ProjectCollaborator, ProjectGitBot, ProjectIssue, ProjectIssueComment, - ProjectTask, Task, TaskModelSwitch, TaskUsageStat, TaskVirtualMachine, Team, - TeamExtensionImageArchive, TeamGroup, TeamGroupHost, TeamGroupImage, - TeamGroupMCPUpstream, TeamGroupMember, TeamGroupModel, TeamHost, TeamImage, - TeamMember, TeamModel, TeamOIDCConfig, User, UserIdentity, + AgentSyncJob, Audit, Endpoint, GitBot, GitBotTask, GitBotUser, GitIdentity, + GitTask, Host, Image, MCPTool, MCPToolCall, MCPUpstream, MCPUserToolSetting, + Model, ModelApiKey, ModelPricing, NotifyChannel, NotifySendLog, + NotifySubscription, Project, ProjectCollaborator, ProjectGitBot, ProjectIssue, + ProjectIssueComment, ProjectTask, Task, TaskModelSwitch, TaskUsageStat, + TaskVirtualMachine, Team, TeamExtensionImageArchive, TeamGroup, TeamGroupHost, + TeamGroupImage, TeamGroupMCPUpstream, TeamGroupMember, TeamGroupModel, + TeamHost, TeamImage, TeamMember, TeamModel, TeamOIDCConfig, User, UserIdentity, VirtualMachine []ent.Hook } inters struct { AgentPlugin, AgentPluginRepo, AgentPluginVersion, AgentRule, AgentRuleVersion, AgentSkill, AgentSkillGroupBinding, AgentSkillRepo, AgentSkillVersion, - AgentSyncJob, Audit, GitBot, GitBotTask, GitBotUser, GitIdentity, GitTask, - Host, Image, MCPTool, MCPToolCall, MCPUpstream, MCPUserToolSetting, Model, - ModelApiKey, ModelPricing, NotifyChannel, NotifySendLog, NotifySubscription, - Project, ProjectCollaborator, ProjectGitBot, ProjectIssue, ProjectIssueComment, - ProjectTask, Task, TaskModelSwitch, TaskUsageStat, TaskVirtualMachine, Team, - TeamExtensionImageArchive, TeamGroup, TeamGroupHost, TeamGroupImage, - TeamGroupMCPUpstream, TeamGroupMember, TeamGroupModel, TeamHost, TeamImage, - TeamMember, TeamModel, TeamOIDCConfig, User, UserIdentity, + AgentSyncJob, Audit, Endpoint, GitBot, GitBotTask, GitBotUser, GitIdentity, + GitTask, Host, Image, MCPTool, MCPToolCall, MCPUpstream, MCPUserToolSetting, + Model, ModelApiKey, ModelPricing, NotifyChannel, NotifySendLog, + NotifySubscription, Project, ProjectCollaborator, ProjectGitBot, ProjectIssue, + ProjectIssueComment, ProjectTask, Task, TaskModelSwitch, TaskUsageStat, + TaskVirtualMachine, Team, TeamExtensionImageArchive, TeamGroup, TeamGroupHost, + TeamGroupImage, TeamGroupMCPUpstream, TeamGroupMember, TeamGroupModel, + TeamHost, TeamImage, TeamMember, TeamModel, TeamOIDCConfig, User, UserIdentity, VirtualMachine []ent.Interceptor } ) diff --git a/backend/db/endpoint.go b/backend/db/endpoint.go new file mode 100644 index 000000000..6783f4832 --- /dev/null +++ b/backend/db/endpoint.go @@ -0,0 +1,276 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "fmt" + "strings" + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" + "github.com/chaitin/MonkeyCode/backend/db/user" + "github.com/google/uuid" +) + +// Endpoint is the model entity for the Endpoint schema. +type Endpoint struct { + config `json:"-"` + // ID of the ent. + ID uuid.UUID `json:"id,omitempty"` + // UserID holds the value of the "user_id" field. + UserID uuid.UUID `json:"user_id,omitempty"` + // MachineID holds the value of the "machine_id" field. + MachineID uuid.UUID `json:"machine_id,omitempty"` + // DeviceName holds the value of the "device_name" field. + DeviceName string `json:"device_name,omitempty"` + // Platform holds the value of the "platform" field. + Platform endpoint.Platform `json:"platform,omitempty"` + // OsVersion holds the value of the "os_version" field. + OsVersion string `json:"os_version,omitempty"` + // Arch holds the value of the "arch" field. + Arch string `json:"arch,omitempty"` + // ClientVersion holds the value of the "client_version" field. + ClientVersion string `json:"client_version,omitempty"` + // ProtocolVersion holds the value of the "protocol_version" field. + ProtocolVersion int `json:"protocol_version,omitempty"` + // Alias holds the value of the "alias" field. + Alias *string `json:"alias,omitempty"` + // Status holds the value of the "status" field. + Status endpoint.Status `json:"status,omitempty"` + // LastSeenAt holds the value of the "last_seen_at" field. + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + // CreatedAt holds the value of the "created_at" field. + CreatedAt time.Time `json:"created_at,omitempty"` + // UpdatedAt holds the value of the "updated_at" field. + UpdatedAt time.Time `json:"updated_at,omitempty"` + // Edges holds the relations/edges for other nodes in the graph. + // The values are being populated by the EndpointQuery when eager-loading is set. + Edges EndpointEdges `json:"edges"` + selectValues sql.SelectValues +} + +// EndpointEdges holds the relations/edges for other nodes in the graph. +type EndpointEdges struct { + // User holds the value of the user edge. + User *User `json:"user,omitempty"` + // loadedTypes holds the information for reporting if a + // type was loaded (or requested) in eager-loading or not. + loadedTypes [1]bool +} + +// UserOrErr returns the User value or an error if the edge +// was not loaded in eager-loading, or loaded but was not found. +func (e EndpointEdges) UserOrErr() (*User, error) { + if e.User != nil { + return e.User, nil + } else if e.loadedTypes[0] { + return nil, &NotFoundError{label: user.Label} + } + return nil, &NotLoadedError{edge: "user"} +} + +// scanValues returns the types for scanning values from sql.Rows. +func (*Endpoint) scanValues(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch columns[i] { + case endpoint.FieldProtocolVersion: + values[i] = new(sql.NullInt64) + case endpoint.FieldDeviceName, endpoint.FieldPlatform, endpoint.FieldOsVersion, endpoint.FieldArch, endpoint.FieldClientVersion, endpoint.FieldAlias, endpoint.FieldStatus: + values[i] = new(sql.NullString) + case endpoint.FieldLastSeenAt, endpoint.FieldCreatedAt, endpoint.FieldUpdatedAt: + values[i] = new(sql.NullTime) + case endpoint.FieldID, endpoint.FieldUserID, endpoint.FieldMachineID: + values[i] = new(uuid.UUID) + default: + values[i] = new(sql.UnknownType) + } + } + return values, nil +} + +// assignValues assigns the values that were returned from sql.Rows (after scanning) +// to the Endpoint fields. +func (_m *Endpoint) assignValues(columns []string, values []any) error { + if m, n := len(values), len(columns); m < n { + return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) + } + for i := range columns { + switch columns[i] { + case endpoint.FieldID: + if value, ok := values[i].(*uuid.UUID); !ok { + return fmt.Errorf("unexpected type %T for field id", values[i]) + } else if value != nil { + _m.ID = *value + } + case endpoint.FieldUserID: + if value, ok := values[i].(*uuid.UUID); !ok { + return fmt.Errorf("unexpected type %T for field user_id", values[i]) + } else if value != nil { + _m.UserID = *value + } + case endpoint.FieldMachineID: + if value, ok := values[i].(*uuid.UUID); !ok { + return fmt.Errorf("unexpected type %T for field machine_id", values[i]) + } else if value != nil { + _m.MachineID = *value + } + case endpoint.FieldDeviceName: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field device_name", values[i]) + } else if value.Valid { + _m.DeviceName = value.String + } + case endpoint.FieldPlatform: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field platform", values[i]) + } else if value.Valid { + _m.Platform = endpoint.Platform(value.String) + } + case endpoint.FieldOsVersion: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field os_version", values[i]) + } else if value.Valid { + _m.OsVersion = value.String + } + case endpoint.FieldArch: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field arch", values[i]) + } else if value.Valid { + _m.Arch = value.String + } + case endpoint.FieldClientVersion: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field client_version", values[i]) + } else if value.Valid { + _m.ClientVersion = value.String + } + case endpoint.FieldProtocolVersion: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field protocol_version", values[i]) + } else if value.Valid { + _m.ProtocolVersion = int(value.Int64) + } + case endpoint.FieldAlias: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field alias", values[i]) + } else if value.Valid { + _m.Alias = new(string) + *_m.Alias = value.String + } + case endpoint.FieldStatus: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field status", values[i]) + } else if value.Valid { + _m.Status = endpoint.Status(value.String) + } + case endpoint.FieldLastSeenAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field last_seen_at", values[i]) + } else if value.Valid { + _m.LastSeenAt = new(time.Time) + *_m.LastSeenAt = value.Time + } + case endpoint.FieldCreatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field created_at", values[i]) + } else if value.Valid { + _m.CreatedAt = value.Time + } + case endpoint.FieldUpdatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field updated_at", values[i]) + } else if value.Valid { + _m.UpdatedAt = value.Time + } + default: + _m.selectValues.Set(columns[i], values[i]) + } + } + return nil +} + +// Value returns the ent.Value that was dynamically selected and assigned to the Endpoint. +// This includes values selected through modifiers, order, etc. +func (_m *Endpoint) Value(name string) (ent.Value, error) { + return _m.selectValues.Get(name) +} + +// QueryUser queries the "user" edge of the Endpoint entity. +func (_m *Endpoint) QueryUser() *UserQuery { + return NewEndpointClient(_m.config).QueryUser(_m) +} + +// Update returns a builder for updating this Endpoint. +// Note that you need to call Endpoint.Unwrap() before calling this method if this Endpoint +// was returned from a transaction, and the transaction was committed or rolled back. +func (_m *Endpoint) Update() *EndpointUpdateOne { + return NewEndpointClient(_m.config).UpdateOne(_m) +} + +// Unwrap unwraps the Endpoint entity that was returned from a transaction after it was closed, +// so that all future queries will be executed through the driver which created the transaction. +func (_m *Endpoint) Unwrap() *Endpoint { + _tx, ok := _m.config.driver.(*txDriver) + if !ok { + panic("db: Endpoint is not a transactional entity") + } + _m.config.driver = _tx.drv + return _m +} + +// String implements the fmt.Stringer. +func (_m *Endpoint) String() string { + var builder strings.Builder + builder.WriteString("Endpoint(") + builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("user_id=") + builder.WriteString(fmt.Sprintf("%v", _m.UserID)) + builder.WriteString(", ") + builder.WriteString("machine_id=") + builder.WriteString(fmt.Sprintf("%v", _m.MachineID)) + builder.WriteString(", ") + builder.WriteString("device_name=") + builder.WriteString(_m.DeviceName) + builder.WriteString(", ") + builder.WriteString("platform=") + builder.WriteString(fmt.Sprintf("%v", _m.Platform)) + builder.WriteString(", ") + builder.WriteString("os_version=") + builder.WriteString(_m.OsVersion) + builder.WriteString(", ") + builder.WriteString("arch=") + builder.WriteString(_m.Arch) + builder.WriteString(", ") + builder.WriteString("client_version=") + builder.WriteString(_m.ClientVersion) + builder.WriteString(", ") + builder.WriteString("protocol_version=") + builder.WriteString(fmt.Sprintf("%v", _m.ProtocolVersion)) + builder.WriteString(", ") + if v := _m.Alias; v != nil { + builder.WriteString("alias=") + builder.WriteString(*v) + } + builder.WriteString(", ") + builder.WriteString("status=") + builder.WriteString(fmt.Sprintf("%v", _m.Status)) + builder.WriteString(", ") + if v := _m.LastSeenAt; v != nil { + builder.WriteString("last_seen_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") + builder.WriteString("created_at=") + builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("updated_at=") + builder.WriteString(_m.UpdatedAt.Format(time.ANSIC)) + builder.WriteByte(')') + return builder.String() +} + +// Endpoints is a parsable slice of Endpoint. +type Endpoints []*Endpoint diff --git a/backend/db/endpoint/endpoint.go b/backend/db/endpoint/endpoint.go new file mode 100644 index 000000000..a52630e9b --- /dev/null +++ b/backend/db/endpoint/endpoint.go @@ -0,0 +1,238 @@ +// Code generated by ent, DO NOT EDIT. + +package endpoint + +import ( + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/google/uuid" +) + +const ( + // Label holds the string label denoting the endpoint type in the database. + Label = "endpoint" + // FieldID holds the string denoting the id field in the database. + FieldID = "id" + // FieldUserID holds the string denoting the user_id field in the database. + FieldUserID = "user_id" + // FieldMachineID holds the string denoting the machine_id field in the database. + FieldMachineID = "machine_id" + // FieldDeviceName holds the string denoting the device_name field in the database. + FieldDeviceName = "device_name" + // FieldPlatform holds the string denoting the platform field in the database. + FieldPlatform = "platform" + // FieldOsVersion holds the string denoting the os_version field in the database. + FieldOsVersion = "os_version" + // FieldArch holds the string denoting the arch field in the database. + FieldArch = "arch" + // FieldClientVersion holds the string denoting the client_version field in the database. + FieldClientVersion = "client_version" + // FieldProtocolVersion holds the string denoting the protocol_version field in the database. + FieldProtocolVersion = "protocol_version" + // FieldAlias holds the string denoting the alias field in the database. + FieldAlias = "alias" + // FieldStatus holds the string denoting the status field in the database. + FieldStatus = "status" + // FieldLastSeenAt holds the string denoting the last_seen_at field in the database. + FieldLastSeenAt = "last_seen_at" + // FieldCreatedAt holds the string denoting the created_at field in the database. + FieldCreatedAt = "created_at" + // FieldUpdatedAt holds the string denoting the updated_at field in the database. + FieldUpdatedAt = "updated_at" + // EdgeUser holds the string denoting the user edge name in mutations. + EdgeUser = "user" + // Table holds the table name of the endpoint in the database. + Table = "endpoints" + // UserTable is the table that holds the user relation/edge. + UserTable = "endpoints" + // UserInverseTable is the table name for the User entity. + // It exists in this package in order to avoid circular dependency with the "user" package. + UserInverseTable = "users" + // UserColumn is the table column denoting the user relation/edge. + UserColumn = "user_id" +) + +// Columns holds all SQL columns for endpoint fields. +var Columns = []string{ + FieldID, + FieldUserID, + FieldMachineID, + FieldDeviceName, + FieldPlatform, + FieldOsVersion, + FieldArch, + FieldClientVersion, + FieldProtocolVersion, + FieldAlias, + FieldStatus, + FieldLastSeenAt, + FieldCreatedAt, + FieldUpdatedAt, +} + +// ValidColumn reports if the column name is valid (part of the table columns). +func ValidColumn(column string) bool { + for i := range Columns { + if column == Columns[i] { + return true + } + } + return false +} + +var ( + // DeviceNameValidator is a validator for the "device_name" field. It is called by the builders before save. + DeviceNameValidator func(string) error + // DefaultProtocolVersion holds the default value on creation for the "protocol_version" field. + DefaultProtocolVersion int + // DefaultCreatedAt holds the default value on creation for the "created_at" field. + DefaultCreatedAt func() time.Time + // DefaultUpdatedAt holds the default value on creation for the "updated_at" field. + DefaultUpdatedAt func() time.Time + // UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field. + UpdateDefaultUpdatedAt func() time.Time + // DefaultID holds the default value on creation for the "id" field. + DefaultID func() uuid.UUID +) + +// Platform defines the type for the "platform" enum field. +type Platform string + +// Platform values. +const ( + PlatformMacos Platform = "macos" + PlatformWindows Platform = "windows" + PlatformLinux Platform = "linux" + PlatformIos Platform = "ios" + PlatformAndroid Platform = "android" +) + +func (pl Platform) String() string { + return string(pl) +} + +// PlatformValidator is a validator for the "platform" field enum values. It is called by the builders before save. +func PlatformValidator(pl Platform) error { + switch pl { + case PlatformMacos, PlatformWindows, PlatformLinux, PlatformIos, PlatformAndroid: + return nil + default: + return fmt.Errorf("endpoint: invalid enum value for platform field: %q", pl) + } +} + +// Status defines the type for the "status" enum field. +type Status string + +// StatusActive is the default value of the Status enum. +const DefaultStatus = StatusActive + +// Status values. +const ( + StatusActive Status = "active" + StatusRevoked Status = "revoked" +) + +func (s Status) String() string { + return string(s) +} + +// StatusValidator is a validator for the "status" field enum values. It is called by the builders before save. +func StatusValidator(s Status) error { + switch s { + case StatusActive, StatusRevoked: + return nil + default: + return fmt.Errorf("endpoint: invalid enum value for status field: %q", s) + } +} + +// OrderOption defines the ordering options for the Endpoint queries. +type OrderOption func(*sql.Selector) + +// ByID orders the results by the id field. +func ByID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldID, opts...).ToFunc() +} + +// ByUserID orders the results by the user_id field. +func ByUserID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUserID, opts...).ToFunc() +} + +// ByMachineID orders the results by the machine_id field. +func ByMachineID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldMachineID, opts...).ToFunc() +} + +// ByDeviceName orders the results by the device_name field. +func ByDeviceName(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldDeviceName, opts...).ToFunc() +} + +// ByPlatform orders the results by the platform field. +func ByPlatform(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldPlatform, opts...).ToFunc() +} + +// ByOsVersion orders the results by the os_version field. +func ByOsVersion(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldOsVersion, opts...).ToFunc() +} + +// ByArch orders the results by the arch field. +func ByArch(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldArch, opts...).ToFunc() +} + +// ByClientVersion orders the results by the client_version field. +func ByClientVersion(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldClientVersion, opts...).ToFunc() +} + +// ByProtocolVersion orders the results by the protocol_version field. +func ByProtocolVersion(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldProtocolVersion, opts...).ToFunc() +} + +// ByAlias orders the results by the alias field. +func ByAlias(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAlias, opts...).ToFunc() +} + +// ByStatus orders the results by the status field. +func ByStatus(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldStatus, opts...).ToFunc() +} + +// ByLastSeenAt orders the results by the last_seen_at field. +func ByLastSeenAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldLastSeenAt, opts...).ToFunc() +} + +// ByCreatedAt orders the results by the created_at field. +func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() +} + +// ByUpdatedAt orders the results by the updated_at field. +func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc() +} + +// ByUserField orders the results by user field. +func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newUserStep(), sql.OrderByField(field, opts...)) + } +} +func newUserStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(UserInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, UserTable, UserColumn), + ) +} diff --git a/backend/db/endpoint/where.go b/backend/db/endpoint/where.go new file mode 100644 index 000000000..9e2d534af --- /dev/null +++ b/backend/db/endpoint/where.go @@ -0,0 +1,755 @@ +// Code generated by ent, DO NOT EDIT. + +package endpoint + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/chaitin/MonkeyCode/backend/db/predicate" + "github.com/google/uuid" +) + +// ID filters vertices based on their ID field. +func ID(id uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldID, id)) +} + +// IDEQ applies the EQ predicate on the ID field. +func IDEQ(id uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldID, id)) +} + +// IDNEQ applies the NEQ predicate on the ID field. +func IDNEQ(id uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldID, id)) +} + +// IDIn applies the In predicate on the ID field. +func IDIn(ids ...uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldID, ids...)) +} + +// IDNotIn applies the NotIn predicate on the ID field. +func IDNotIn(ids ...uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldID, ids...)) +} + +// IDGT applies the GT predicate on the ID field. +func IDGT(id uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGT(FieldID, id)) +} + +// IDGTE applies the GTE predicate on the ID field. +func IDGTE(id uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGTE(FieldID, id)) +} + +// IDLT applies the LT predicate on the ID field. +func IDLT(id uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLT(FieldID, id)) +} + +// IDLTE applies the LTE predicate on the ID field. +func IDLTE(id uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLTE(FieldID, id)) +} + +// UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. +func UserID(v uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldUserID, v)) +} + +// MachineID applies equality check predicate on the "machine_id" field. It's identical to MachineIDEQ. +func MachineID(v uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldMachineID, v)) +} + +// DeviceName applies equality check predicate on the "device_name" field. It's identical to DeviceNameEQ. +func DeviceName(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldDeviceName, v)) +} + +// OsVersion applies equality check predicate on the "os_version" field. It's identical to OsVersionEQ. +func OsVersion(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldOsVersion, v)) +} + +// Arch applies equality check predicate on the "arch" field. It's identical to ArchEQ. +func Arch(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldArch, v)) +} + +// ClientVersion applies equality check predicate on the "client_version" field. It's identical to ClientVersionEQ. +func ClientVersion(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldClientVersion, v)) +} + +// ProtocolVersion applies equality check predicate on the "protocol_version" field. It's identical to ProtocolVersionEQ. +func ProtocolVersion(v int) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldProtocolVersion, v)) +} + +// Alias applies equality check predicate on the "alias" field. It's identical to AliasEQ. +func Alias(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldAlias, v)) +} + +// LastSeenAt applies equality check predicate on the "last_seen_at" field. It's identical to LastSeenAtEQ. +func LastSeenAt(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldLastSeenAt, v)) +} + +// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. +func CreatedAt(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldCreatedAt, v)) +} + +// UpdatedAt applies equality check predicate on the "updated_at" field. It's identical to UpdatedAtEQ. +func UpdatedAt(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// UserIDEQ applies the EQ predicate on the "user_id" field. +func UserIDEQ(v uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldUserID, v)) +} + +// UserIDNEQ applies the NEQ predicate on the "user_id" field. +func UserIDNEQ(v uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldUserID, v)) +} + +// UserIDIn applies the In predicate on the "user_id" field. +func UserIDIn(vs ...uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldUserID, vs...)) +} + +// UserIDNotIn applies the NotIn predicate on the "user_id" field. +func UserIDNotIn(vs ...uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldUserID, vs...)) +} + +// MachineIDEQ applies the EQ predicate on the "machine_id" field. +func MachineIDEQ(v uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldMachineID, v)) +} + +// MachineIDNEQ applies the NEQ predicate on the "machine_id" field. +func MachineIDNEQ(v uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldMachineID, v)) +} + +// MachineIDIn applies the In predicate on the "machine_id" field. +func MachineIDIn(vs ...uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldMachineID, vs...)) +} + +// MachineIDNotIn applies the NotIn predicate on the "machine_id" field. +func MachineIDNotIn(vs ...uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldMachineID, vs...)) +} + +// MachineIDGT applies the GT predicate on the "machine_id" field. +func MachineIDGT(v uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGT(FieldMachineID, v)) +} + +// MachineIDGTE applies the GTE predicate on the "machine_id" field. +func MachineIDGTE(v uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGTE(FieldMachineID, v)) +} + +// MachineIDLT applies the LT predicate on the "machine_id" field. +func MachineIDLT(v uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLT(FieldMachineID, v)) +} + +// MachineIDLTE applies the LTE predicate on the "machine_id" field. +func MachineIDLTE(v uuid.UUID) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLTE(FieldMachineID, v)) +} + +// DeviceNameEQ applies the EQ predicate on the "device_name" field. +func DeviceNameEQ(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldDeviceName, v)) +} + +// DeviceNameNEQ applies the NEQ predicate on the "device_name" field. +func DeviceNameNEQ(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldDeviceName, v)) +} + +// DeviceNameIn applies the In predicate on the "device_name" field. +func DeviceNameIn(vs ...string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldDeviceName, vs...)) +} + +// DeviceNameNotIn applies the NotIn predicate on the "device_name" field. +func DeviceNameNotIn(vs ...string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldDeviceName, vs...)) +} + +// DeviceNameGT applies the GT predicate on the "device_name" field. +func DeviceNameGT(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGT(FieldDeviceName, v)) +} + +// DeviceNameGTE applies the GTE predicate on the "device_name" field. +func DeviceNameGTE(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGTE(FieldDeviceName, v)) +} + +// DeviceNameLT applies the LT predicate on the "device_name" field. +func DeviceNameLT(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLT(FieldDeviceName, v)) +} + +// DeviceNameLTE applies the LTE predicate on the "device_name" field. +func DeviceNameLTE(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLTE(FieldDeviceName, v)) +} + +// DeviceNameContains applies the Contains predicate on the "device_name" field. +func DeviceNameContains(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldContains(FieldDeviceName, v)) +} + +// DeviceNameHasPrefix applies the HasPrefix predicate on the "device_name" field. +func DeviceNameHasPrefix(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldHasPrefix(FieldDeviceName, v)) +} + +// DeviceNameHasSuffix applies the HasSuffix predicate on the "device_name" field. +func DeviceNameHasSuffix(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldHasSuffix(FieldDeviceName, v)) +} + +// DeviceNameEqualFold applies the EqualFold predicate on the "device_name" field. +func DeviceNameEqualFold(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEqualFold(FieldDeviceName, v)) +} + +// DeviceNameContainsFold applies the ContainsFold predicate on the "device_name" field. +func DeviceNameContainsFold(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldContainsFold(FieldDeviceName, v)) +} + +// PlatformEQ applies the EQ predicate on the "platform" field. +func PlatformEQ(v Platform) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldPlatform, v)) +} + +// PlatformNEQ applies the NEQ predicate on the "platform" field. +func PlatformNEQ(v Platform) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldPlatform, v)) +} + +// PlatformIn applies the In predicate on the "platform" field. +func PlatformIn(vs ...Platform) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldPlatform, vs...)) +} + +// PlatformNotIn applies the NotIn predicate on the "platform" field. +func PlatformNotIn(vs ...Platform) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldPlatform, vs...)) +} + +// OsVersionEQ applies the EQ predicate on the "os_version" field. +func OsVersionEQ(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldOsVersion, v)) +} + +// OsVersionNEQ applies the NEQ predicate on the "os_version" field. +func OsVersionNEQ(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldOsVersion, v)) +} + +// OsVersionIn applies the In predicate on the "os_version" field. +func OsVersionIn(vs ...string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldOsVersion, vs...)) +} + +// OsVersionNotIn applies the NotIn predicate on the "os_version" field. +func OsVersionNotIn(vs ...string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldOsVersion, vs...)) +} + +// OsVersionGT applies the GT predicate on the "os_version" field. +func OsVersionGT(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGT(FieldOsVersion, v)) +} + +// OsVersionGTE applies the GTE predicate on the "os_version" field. +func OsVersionGTE(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGTE(FieldOsVersion, v)) +} + +// OsVersionLT applies the LT predicate on the "os_version" field. +func OsVersionLT(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLT(FieldOsVersion, v)) +} + +// OsVersionLTE applies the LTE predicate on the "os_version" field. +func OsVersionLTE(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLTE(FieldOsVersion, v)) +} + +// OsVersionContains applies the Contains predicate on the "os_version" field. +func OsVersionContains(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldContains(FieldOsVersion, v)) +} + +// OsVersionHasPrefix applies the HasPrefix predicate on the "os_version" field. +func OsVersionHasPrefix(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldHasPrefix(FieldOsVersion, v)) +} + +// OsVersionHasSuffix applies the HasSuffix predicate on the "os_version" field. +func OsVersionHasSuffix(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldHasSuffix(FieldOsVersion, v)) +} + +// OsVersionEqualFold applies the EqualFold predicate on the "os_version" field. +func OsVersionEqualFold(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEqualFold(FieldOsVersion, v)) +} + +// OsVersionContainsFold applies the ContainsFold predicate on the "os_version" field. +func OsVersionContainsFold(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldContainsFold(FieldOsVersion, v)) +} + +// ArchEQ applies the EQ predicate on the "arch" field. +func ArchEQ(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldArch, v)) +} + +// ArchNEQ applies the NEQ predicate on the "arch" field. +func ArchNEQ(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldArch, v)) +} + +// ArchIn applies the In predicate on the "arch" field. +func ArchIn(vs ...string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldArch, vs...)) +} + +// ArchNotIn applies the NotIn predicate on the "arch" field. +func ArchNotIn(vs ...string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldArch, vs...)) +} + +// ArchGT applies the GT predicate on the "arch" field. +func ArchGT(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGT(FieldArch, v)) +} + +// ArchGTE applies the GTE predicate on the "arch" field. +func ArchGTE(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGTE(FieldArch, v)) +} + +// ArchLT applies the LT predicate on the "arch" field. +func ArchLT(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLT(FieldArch, v)) +} + +// ArchLTE applies the LTE predicate on the "arch" field. +func ArchLTE(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLTE(FieldArch, v)) +} + +// ArchContains applies the Contains predicate on the "arch" field. +func ArchContains(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldContains(FieldArch, v)) +} + +// ArchHasPrefix applies the HasPrefix predicate on the "arch" field. +func ArchHasPrefix(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldHasPrefix(FieldArch, v)) +} + +// ArchHasSuffix applies the HasSuffix predicate on the "arch" field. +func ArchHasSuffix(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldHasSuffix(FieldArch, v)) +} + +// ArchEqualFold applies the EqualFold predicate on the "arch" field. +func ArchEqualFold(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEqualFold(FieldArch, v)) +} + +// ArchContainsFold applies the ContainsFold predicate on the "arch" field. +func ArchContainsFold(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldContainsFold(FieldArch, v)) +} + +// ClientVersionEQ applies the EQ predicate on the "client_version" field. +func ClientVersionEQ(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldClientVersion, v)) +} + +// ClientVersionNEQ applies the NEQ predicate on the "client_version" field. +func ClientVersionNEQ(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldClientVersion, v)) +} + +// ClientVersionIn applies the In predicate on the "client_version" field. +func ClientVersionIn(vs ...string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldClientVersion, vs...)) +} + +// ClientVersionNotIn applies the NotIn predicate on the "client_version" field. +func ClientVersionNotIn(vs ...string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldClientVersion, vs...)) +} + +// ClientVersionGT applies the GT predicate on the "client_version" field. +func ClientVersionGT(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGT(FieldClientVersion, v)) +} + +// ClientVersionGTE applies the GTE predicate on the "client_version" field. +func ClientVersionGTE(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGTE(FieldClientVersion, v)) +} + +// ClientVersionLT applies the LT predicate on the "client_version" field. +func ClientVersionLT(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLT(FieldClientVersion, v)) +} + +// ClientVersionLTE applies the LTE predicate on the "client_version" field. +func ClientVersionLTE(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLTE(FieldClientVersion, v)) +} + +// ClientVersionContains applies the Contains predicate on the "client_version" field. +func ClientVersionContains(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldContains(FieldClientVersion, v)) +} + +// ClientVersionHasPrefix applies the HasPrefix predicate on the "client_version" field. +func ClientVersionHasPrefix(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldHasPrefix(FieldClientVersion, v)) +} + +// ClientVersionHasSuffix applies the HasSuffix predicate on the "client_version" field. +func ClientVersionHasSuffix(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldHasSuffix(FieldClientVersion, v)) +} + +// ClientVersionEqualFold applies the EqualFold predicate on the "client_version" field. +func ClientVersionEqualFold(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEqualFold(FieldClientVersion, v)) +} + +// ClientVersionContainsFold applies the ContainsFold predicate on the "client_version" field. +func ClientVersionContainsFold(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldContainsFold(FieldClientVersion, v)) +} + +// ProtocolVersionEQ applies the EQ predicate on the "protocol_version" field. +func ProtocolVersionEQ(v int) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldProtocolVersion, v)) +} + +// ProtocolVersionNEQ applies the NEQ predicate on the "protocol_version" field. +func ProtocolVersionNEQ(v int) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldProtocolVersion, v)) +} + +// ProtocolVersionIn applies the In predicate on the "protocol_version" field. +func ProtocolVersionIn(vs ...int) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldProtocolVersion, vs...)) +} + +// ProtocolVersionNotIn applies the NotIn predicate on the "protocol_version" field. +func ProtocolVersionNotIn(vs ...int) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldProtocolVersion, vs...)) +} + +// ProtocolVersionGT applies the GT predicate on the "protocol_version" field. +func ProtocolVersionGT(v int) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGT(FieldProtocolVersion, v)) +} + +// ProtocolVersionGTE applies the GTE predicate on the "protocol_version" field. +func ProtocolVersionGTE(v int) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGTE(FieldProtocolVersion, v)) +} + +// ProtocolVersionLT applies the LT predicate on the "protocol_version" field. +func ProtocolVersionLT(v int) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLT(FieldProtocolVersion, v)) +} + +// ProtocolVersionLTE applies the LTE predicate on the "protocol_version" field. +func ProtocolVersionLTE(v int) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLTE(FieldProtocolVersion, v)) +} + +// AliasEQ applies the EQ predicate on the "alias" field. +func AliasEQ(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldAlias, v)) +} + +// AliasNEQ applies the NEQ predicate on the "alias" field. +func AliasNEQ(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldAlias, v)) +} + +// AliasIn applies the In predicate on the "alias" field. +func AliasIn(vs ...string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldAlias, vs...)) +} + +// AliasNotIn applies the NotIn predicate on the "alias" field. +func AliasNotIn(vs ...string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldAlias, vs...)) +} + +// AliasGT applies the GT predicate on the "alias" field. +func AliasGT(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGT(FieldAlias, v)) +} + +// AliasGTE applies the GTE predicate on the "alias" field. +func AliasGTE(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGTE(FieldAlias, v)) +} + +// AliasLT applies the LT predicate on the "alias" field. +func AliasLT(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLT(FieldAlias, v)) +} + +// AliasLTE applies the LTE predicate on the "alias" field. +func AliasLTE(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLTE(FieldAlias, v)) +} + +// AliasContains applies the Contains predicate on the "alias" field. +func AliasContains(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldContains(FieldAlias, v)) +} + +// AliasHasPrefix applies the HasPrefix predicate on the "alias" field. +func AliasHasPrefix(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldHasPrefix(FieldAlias, v)) +} + +// AliasHasSuffix applies the HasSuffix predicate on the "alias" field. +func AliasHasSuffix(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldHasSuffix(FieldAlias, v)) +} + +// AliasIsNil applies the IsNil predicate on the "alias" field. +func AliasIsNil() predicate.Endpoint { + return predicate.Endpoint(sql.FieldIsNull(FieldAlias)) +} + +// AliasNotNil applies the NotNil predicate on the "alias" field. +func AliasNotNil() predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotNull(FieldAlias)) +} + +// AliasEqualFold applies the EqualFold predicate on the "alias" field. +func AliasEqualFold(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEqualFold(FieldAlias, v)) +} + +// AliasContainsFold applies the ContainsFold predicate on the "alias" field. +func AliasContainsFold(v string) predicate.Endpoint { + return predicate.Endpoint(sql.FieldContainsFold(FieldAlias, v)) +} + +// StatusEQ applies the EQ predicate on the "status" field. +func StatusEQ(v Status) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldStatus, v)) +} + +// StatusNEQ applies the NEQ predicate on the "status" field. +func StatusNEQ(v Status) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldStatus, v)) +} + +// StatusIn applies the In predicate on the "status" field. +func StatusIn(vs ...Status) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldStatus, vs...)) +} + +// StatusNotIn applies the NotIn predicate on the "status" field. +func StatusNotIn(vs ...Status) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldStatus, vs...)) +} + +// LastSeenAtEQ applies the EQ predicate on the "last_seen_at" field. +func LastSeenAtEQ(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldLastSeenAt, v)) +} + +// LastSeenAtNEQ applies the NEQ predicate on the "last_seen_at" field. +func LastSeenAtNEQ(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldLastSeenAt, v)) +} + +// LastSeenAtIn applies the In predicate on the "last_seen_at" field. +func LastSeenAtIn(vs ...time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldLastSeenAt, vs...)) +} + +// LastSeenAtNotIn applies the NotIn predicate on the "last_seen_at" field. +func LastSeenAtNotIn(vs ...time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldLastSeenAt, vs...)) +} + +// LastSeenAtGT applies the GT predicate on the "last_seen_at" field. +func LastSeenAtGT(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGT(FieldLastSeenAt, v)) +} + +// LastSeenAtGTE applies the GTE predicate on the "last_seen_at" field. +func LastSeenAtGTE(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGTE(FieldLastSeenAt, v)) +} + +// LastSeenAtLT applies the LT predicate on the "last_seen_at" field. +func LastSeenAtLT(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLT(FieldLastSeenAt, v)) +} + +// LastSeenAtLTE applies the LTE predicate on the "last_seen_at" field. +func LastSeenAtLTE(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLTE(FieldLastSeenAt, v)) +} + +// LastSeenAtIsNil applies the IsNil predicate on the "last_seen_at" field. +func LastSeenAtIsNil() predicate.Endpoint { + return predicate.Endpoint(sql.FieldIsNull(FieldLastSeenAt)) +} + +// LastSeenAtNotNil applies the NotNil predicate on the "last_seen_at" field. +func LastSeenAtNotNil() predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotNull(FieldLastSeenAt)) +} + +// CreatedAtEQ applies the EQ predicate on the "created_at" field. +func CreatedAtEQ(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldCreatedAt, v)) +} + +// CreatedAtNEQ applies the NEQ predicate on the "created_at" field. +func CreatedAtNEQ(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldCreatedAt, v)) +} + +// CreatedAtIn applies the In predicate on the "created_at" field. +func CreatedAtIn(vs ...time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldCreatedAt, vs...)) +} + +// CreatedAtNotIn applies the NotIn predicate on the "created_at" field. +func CreatedAtNotIn(vs ...time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldCreatedAt, vs...)) +} + +// CreatedAtGT applies the GT predicate on the "created_at" field. +func CreatedAtGT(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGT(FieldCreatedAt, v)) +} + +// CreatedAtGTE applies the GTE predicate on the "created_at" field. +func CreatedAtGTE(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGTE(FieldCreatedAt, v)) +} + +// CreatedAtLT applies the LT predicate on the "created_at" field. +func CreatedAtLT(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLT(FieldCreatedAt, v)) +} + +// CreatedAtLTE applies the LTE predicate on the "created_at" field. +func CreatedAtLTE(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLTE(FieldCreatedAt, v)) +} + +// UpdatedAtEQ applies the EQ predicate on the "updated_at" field. +func UpdatedAtEQ(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtNEQ applies the NEQ predicate on the "updated_at" field. +func UpdatedAtNEQ(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtIn applies the In predicate on the "updated_at" field. +func UpdatedAtIn(vs ...time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtNotIn applies the NotIn predicate on the "updated_at" field. +func UpdatedAtNotIn(vs ...time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldNotIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtGT applies the GT predicate on the "updated_at" field. +func UpdatedAtGT(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGT(FieldUpdatedAt, v)) +} + +// UpdatedAtGTE applies the GTE predicate on the "updated_at" field. +func UpdatedAtGTE(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldGTE(FieldUpdatedAt, v)) +} + +// UpdatedAtLT applies the LT predicate on the "updated_at" field. +func UpdatedAtLT(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLT(FieldUpdatedAt, v)) +} + +// UpdatedAtLTE applies the LTE predicate on the "updated_at" field. +func UpdatedAtLTE(v time.Time) predicate.Endpoint { + return predicate.Endpoint(sql.FieldLTE(FieldUpdatedAt, v)) +} + +// HasUser applies the HasEdge predicate on the "user" edge. +func HasUser() predicate.Endpoint { + return predicate.Endpoint(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, UserTable, UserColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasUserWith applies the HasEdge predicate on the "user" edge with a given conditions (other predicates). +func HasUserWith(preds ...predicate.User) predicate.Endpoint { + return predicate.Endpoint(func(s *sql.Selector) { + step := newUserStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// And groups predicates with the AND operator between them. +func And(predicates ...predicate.Endpoint) predicate.Endpoint { + return predicate.Endpoint(sql.AndPredicates(predicates...)) +} + +// Or groups predicates with the OR operator between them. +func Or(predicates ...predicate.Endpoint) predicate.Endpoint { + return predicate.Endpoint(sql.OrPredicates(predicates...)) +} + +// Not applies the not operator on the given predicate. +func Not(p predicate.Endpoint) predicate.Endpoint { + return predicate.Endpoint(sql.NotPredicates(p)) +} diff --git a/backend/db/endpoint_create.go b/backend/db/endpoint_create.go new file mode 100644 index 000000000..89c33125f --- /dev/null +++ b/backend/db/endpoint_create.go @@ -0,0 +1,1300 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" + "github.com/chaitin/MonkeyCode/backend/db/user" + "github.com/google/uuid" +) + +// EndpointCreate is the builder for creating a Endpoint entity. +type EndpointCreate struct { + config + mutation *EndpointMutation + hooks []Hook + conflict []sql.ConflictOption +} + +// SetUserID sets the "user_id" field. +func (_c *EndpointCreate) SetUserID(v uuid.UUID) *EndpointCreate { + _c.mutation.SetUserID(v) + return _c +} + +// SetMachineID sets the "machine_id" field. +func (_c *EndpointCreate) SetMachineID(v uuid.UUID) *EndpointCreate { + _c.mutation.SetMachineID(v) + return _c +} + +// SetDeviceName sets the "device_name" field. +func (_c *EndpointCreate) SetDeviceName(v string) *EndpointCreate { + _c.mutation.SetDeviceName(v) + return _c +} + +// SetPlatform sets the "platform" field. +func (_c *EndpointCreate) SetPlatform(v endpoint.Platform) *EndpointCreate { + _c.mutation.SetPlatform(v) + return _c +} + +// SetOsVersion sets the "os_version" field. +func (_c *EndpointCreate) SetOsVersion(v string) *EndpointCreate { + _c.mutation.SetOsVersion(v) + return _c +} + +// SetArch sets the "arch" field. +func (_c *EndpointCreate) SetArch(v string) *EndpointCreate { + _c.mutation.SetArch(v) + return _c +} + +// SetClientVersion sets the "client_version" field. +func (_c *EndpointCreate) SetClientVersion(v string) *EndpointCreate { + _c.mutation.SetClientVersion(v) + return _c +} + +// SetProtocolVersion sets the "protocol_version" field. +func (_c *EndpointCreate) SetProtocolVersion(v int) *EndpointCreate { + _c.mutation.SetProtocolVersion(v) + return _c +} + +// SetNillableProtocolVersion sets the "protocol_version" field if the given value is not nil. +func (_c *EndpointCreate) SetNillableProtocolVersion(v *int) *EndpointCreate { + if v != nil { + _c.SetProtocolVersion(*v) + } + return _c +} + +// SetAlias sets the "alias" field. +func (_c *EndpointCreate) SetAlias(v string) *EndpointCreate { + _c.mutation.SetAlias(v) + return _c +} + +// SetNillableAlias sets the "alias" field if the given value is not nil. +func (_c *EndpointCreate) SetNillableAlias(v *string) *EndpointCreate { + if v != nil { + _c.SetAlias(*v) + } + return _c +} + +// SetStatus sets the "status" field. +func (_c *EndpointCreate) SetStatus(v endpoint.Status) *EndpointCreate { + _c.mutation.SetStatus(v) + return _c +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_c *EndpointCreate) SetNillableStatus(v *endpoint.Status) *EndpointCreate { + if v != nil { + _c.SetStatus(*v) + } + return _c +} + +// SetLastSeenAt sets the "last_seen_at" field. +func (_c *EndpointCreate) SetLastSeenAt(v time.Time) *EndpointCreate { + _c.mutation.SetLastSeenAt(v) + return _c +} + +// SetNillableLastSeenAt sets the "last_seen_at" field if the given value is not nil. +func (_c *EndpointCreate) SetNillableLastSeenAt(v *time.Time) *EndpointCreate { + if v != nil { + _c.SetLastSeenAt(*v) + } + return _c +} + +// SetCreatedAt sets the "created_at" field. +func (_c *EndpointCreate) SetCreatedAt(v time.Time) *EndpointCreate { + _c.mutation.SetCreatedAt(v) + return _c +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_c *EndpointCreate) SetNillableCreatedAt(v *time.Time) *EndpointCreate { + if v != nil { + _c.SetCreatedAt(*v) + } + return _c +} + +// SetUpdatedAt sets the "updated_at" field. +func (_c *EndpointCreate) SetUpdatedAt(v time.Time) *EndpointCreate { + _c.mutation.SetUpdatedAt(v) + return _c +} + +// SetNillableUpdatedAt sets the "updated_at" field if the given value is not nil. +func (_c *EndpointCreate) SetNillableUpdatedAt(v *time.Time) *EndpointCreate { + if v != nil { + _c.SetUpdatedAt(*v) + } + return _c +} + +// SetID sets the "id" field. +func (_c *EndpointCreate) SetID(v uuid.UUID) *EndpointCreate { + _c.mutation.SetID(v) + return _c +} + +// SetNillableID sets the "id" field if the given value is not nil. +func (_c *EndpointCreate) SetNillableID(v *uuid.UUID) *EndpointCreate { + if v != nil { + _c.SetID(*v) + } + return _c +} + +// SetUser sets the "user" edge to the User entity. +func (_c *EndpointCreate) SetUser(v *User) *EndpointCreate { + return _c.SetUserID(v.ID) +} + +// Mutation returns the EndpointMutation object of the builder. +func (_c *EndpointCreate) Mutation() *EndpointMutation { + return _c.mutation +} + +// Save creates the Endpoint in the database. +func (_c *EndpointCreate) Save(ctx context.Context) (*Endpoint, error) { + _c.defaults() + return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) +} + +// SaveX calls Save and panics if Save returns an error. +func (_c *EndpointCreate) SaveX(ctx context.Context) *Endpoint { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *EndpointCreate) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *EndpointCreate) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_c *EndpointCreate) defaults() { + if _, ok := _c.mutation.ProtocolVersion(); !ok { + v := endpoint.DefaultProtocolVersion + _c.mutation.SetProtocolVersion(v) + } + if _, ok := _c.mutation.Status(); !ok { + v := endpoint.DefaultStatus + _c.mutation.SetStatus(v) + } + if _, ok := _c.mutation.CreatedAt(); !ok { + v := endpoint.DefaultCreatedAt() + _c.mutation.SetCreatedAt(v) + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + v := endpoint.DefaultUpdatedAt() + _c.mutation.SetUpdatedAt(v) + } + if _, ok := _c.mutation.ID(); !ok { + v := endpoint.DefaultID() + _c.mutation.SetID(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_c *EndpointCreate) check() error { + if _, ok := _c.mutation.UserID(); !ok { + return &ValidationError{Name: "user_id", err: errors.New(`db: missing required field "Endpoint.user_id"`)} + } + if _, ok := _c.mutation.MachineID(); !ok { + return &ValidationError{Name: "machine_id", err: errors.New(`db: missing required field "Endpoint.machine_id"`)} + } + if _, ok := _c.mutation.DeviceName(); !ok { + return &ValidationError{Name: "device_name", err: errors.New(`db: missing required field "Endpoint.device_name"`)} + } + if v, ok := _c.mutation.DeviceName(); ok { + if err := endpoint.DeviceNameValidator(v); err != nil { + return &ValidationError{Name: "device_name", err: fmt.Errorf(`db: validator failed for field "Endpoint.device_name": %w`, err)} + } + } + if _, ok := _c.mutation.Platform(); !ok { + return &ValidationError{Name: "platform", err: errors.New(`db: missing required field "Endpoint.platform"`)} + } + if v, ok := _c.mutation.Platform(); ok { + if err := endpoint.PlatformValidator(v); err != nil { + return &ValidationError{Name: "platform", err: fmt.Errorf(`db: validator failed for field "Endpoint.platform": %w`, err)} + } + } + if _, ok := _c.mutation.OsVersion(); !ok { + return &ValidationError{Name: "os_version", err: errors.New(`db: missing required field "Endpoint.os_version"`)} + } + if _, ok := _c.mutation.Arch(); !ok { + return &ValidationError{Name: "arch", err: errors.New(`db: missing required field "Endpoint.arch"`)} + } + if _, ok := _c.mutation.ClientVersion(); !ok { + return &ValidationError{Name: "client_version", err: errors.New(`db: missing required field "Endpoint.client_version"`)} + } + if _, ok := _c.mutation.ProtocolVersion(); !ok { + return &ValidationError{Name: "protocol_version", err: errors.New(`db: missing required field "Endpoint.protocol_version"`)} + } + if _, ok := _c.mutation.Status(); !ok { + return &ValidationError{Name: "status", err: errors.New(`db: missing required field "Endpoint.status"`)} + } + if v, ok := _c.mutation.Status(); ok { + if err := endpoint.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`db: validator failed for field "Endpoint.status": %w`, err)} + } + } + if _, ok := _c.mutation.CreatedAt(); !ok { + return &ValidationError{Name: "created_at", err: errors.New(`db: missing required field "Endpoint.created_at"`)} + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + return &ValidationError{Name: "updated_at", err: errors.New(`db: missing required field "Endpoint.updated_at"`)} + } + if len(_c.mutation.UserIDs()) == 0 { + return &ValidationError{Name: "user", err: errors.New(`db: missing required edge "Endpoint.user"`)} + } + return nil +} + +func (_c *EndpointCreate) sqlSave(ctx context.Context) (*Endpoint, error) { + if err := _c.check(); err != nil { + return nil, err + } + _node, _spec := _c.createSpec() + if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + if _spec.ID.Value != nil { + if id, ok := _spec.ID.Value.(*uuid.UUID); ok { + _node.ID = *id + } else if err := _node.ID.Scan(_spec.ID.Value); err != nil { + return nil, err + } + } + _c.mutation.id = &_node.ID + _c.mutation.done = true + return _node, nil +} + +func (_c *EndpointCreate) createSpec() (*Endpoint, *sqlgraph.CreateSpec) { + var ( + _node = &Endpoint{config: _c.config} + _spec = sqlgraph.NewCreateSpec(endpoint.Table, sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID)) + ) + _spec.OnConflict = _c.conflict + if id, ok := _c.mutation.ID(); ok { + _node.ID = id + _spec.ID.Value = &id + } + if value, ok := _c.mutation.MachineID(); ok { + _spec.SetField(endpoint.FieldMachineID, field.TypeUUID, value) + _node.MachineID = value + } + if value, ok := _c.mutation.DeviceName(); ok { + _spec.SetField(endpoint.FieldDeviceName, field.TypeString, value) + _node.DeviceName = value + } + if value, ok := _c.mutation.Platform(); ok { + _spec.SetField(endpoint.FieldPlatform, field.TypeEnum, value) + _node.Platform = value + } + if value, ok := _c.mutation.OsVersion(); ok { + _spec.SetField(endpoint.FieldOsVersion, field.TypeString, value) + _node.OsVersion = value + } + if value, ok := _c.mutation.Arch(); ok { + _spec.SetField(endpoint.FieldArch, field.TypeString, value) + _node.Arch = value + } + if value, ok := _c.mutation.ClientVersion(); ok { + _spec.SetField(endpoint.FieldClientVersion, field.TypeString, value) + _node.ClientVersion = value + } + if value, ok := _c.mutation.ProtocolVersion(); ok { + _spec.SetField(endpoint.FieldProtocolVersion, field.TypeInt, value) + _node.ProtocolVersion = value + } + if value, ok := _c.mutation.Alias(); ok { + _spec.SetField(endpoint.FieldAlias, field.TypeString, value) + _node.Alias = &value + } + if value, ok := _c.mutation.Status(); ok { + _spec.SetField(endpoint.FieldStatus, field.TypeEnum, value) + _node.Status = value + } + if value, ok := _c.mutation.LastSeenAt(); ok { + _spec.SetField(endpoint.FieldLastSeenAt, field.TypeTime, value) + _node.LastSeenAt = &value + } + if value, ok := _c.mutation.CreatedAt(); ok { + _spec.SetField(endpoint.FieldCreatedAt, field.TypeTime, value) + _node.CreatedAt = value + } + if value, ok := _c.mutation.UpdatedAt(); ok { + _spec.SetField(endpoint.FieldUpdatedAt, field.TypeTime, value) + _node.UpdatedAt = value + } + if nodes := _c.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: endpoint.UserTable, + Columns: []string{endpoint.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _node.UserID = nodes[0] + _spec.Edges = append(_spec.Edges, edge) + } + return _node, _spec +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.Endpoint.Create(). +// SetUserID(v). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.EndpointUpsert) { +// SetUserID(v+v). +// }). +// Exec(ctx) +func (_c *EndpointCreate) OnConflict(opts ...sql.ConflictOption) *EndpointUpsertOne { + _c.conflict = opts + return &EndpointUpsertOne{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.Endpoint.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *EndpointCreate) OnConflictColumns(columns ...string) *EndpointUpsertOne { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &EndpointUpsertOne{ + create: _c, + } +} + +type ( + // EndpointUpsertOne is the builder for "upsert"-ing + // one Endpoint node. + EndpointUpsertOne struct { + create *EndpointCreate + } + + // EndpointUpsert is the "OnConflict" setter. + EndpointUpsert struct { + *sql.UpdateSet + } +) + +// SetUserID sets the "user_id" field. +func (u *EndpointUpsert) SetUserID(v uuid.UUID) *EndpointUpsert { + u.Set(endpoint.FieldUserID, v) + return u +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateUserID() *EndpointUpsert { + u.SetExcluded(endpoint.FieldUserID) + return u +} + +// SetMachineID sets the "machine_id" field. +func (u *EndpointUpsert) SetMachineID(v uuid.UUID) *EndpointUpsert { + u.Set(endpoint.FieldMachineID, v) + return u +} + +// UpdateMachineID sets the "machine_id" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateMachineID() *EndpointUpsert { + u.SetExcluded(endpoint.FieldMachineID) + return u +} + +// SetDeviceName sets the "device_name" field. +func (u *EndpointUpsert) SetDeviceName(v string) *EndpointUpsert { + u.Set(endpoint.FieldDeviceName, v) + return u +} + +// UpdateDeviceName sets the "device_name" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateDeviceName() *EndpointUpsert { + u.SetExcluded(endpoint.FieldDeviceName) + return u +} + +// SetPlatform sets the "platform" field. +func (u *EndpointUpsert) SetPlatform(v endpoint.Platform) *EndpointUpsert { + u.Set(endpoint.FieldPlatform, v) + return u +} + +// UpdatePlatform sets the "platform" field to the value that was provided on create. +func (u *EndpointUpsert) UpdatePlatform() *EndpointUpsert { + u.SetExcluded(endpoint.FieldPlatform) + return u +} + +// SetOsVersion sets the "os_version" field. +func (u *EndpointUpsert) SetOsVersion(v string) *EndpointUpsert { + u.Set(endpoint.FieldOsVersion, v) + return u +} + +// UpdateOsVersion sets the "os_version" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateOsVersion() *EndpointUpsert { + u.SetExcluded(endpoint.FieldOsVersion) + return u +} + +// SetArch sets the "arch" field. +func (u *EndpointUpsert) SetArch(v string) *EndpointUpsert { + u.Set(endpoint.FieldArch, v) + return u +} + +// UpdateArch sets the "arch" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateArch() *EndpointUpsert { + u.SetExcluded(endpoint.FieldArch) + return u +} + +// SetClientVersion sets the "client_version" field. +func (u *EndpointUpsert) SetClientVersion(v string) *EndpointUpsert { + u.Set(endpoint.FieldClientVersion, v) + return u +} + +// UpdateClientVersion sets the "client_version" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateClientVersion() *EndpointUpsert { + u.SetExcluded(endpoint.FieldClientVersion) + return u +} + +// SetProtocolVersion sets the "protocol_version" field. +func (u *EndpointUpsert) SetProtocolVersion(v int) *EndpointUpsert { + u.Set(endpoint.FieldProtocolVersion, v) + return u +} + +// UpdateProtocolVersion sets the "protocol_version" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateProtocolVersion() *EndpointUpsert { + u.SetExcluded(endpoint.FieldProtocolVersion) + return u +} + +// AddProtocolVersion adds v to the "protocol_version" field. +func (u *EndpointUpsert) AddProtocolVersion(v int) *EndpointUpsert { + u.Add(endpoint.FieldProtocolVersion, v) + return u +} + +// SetAlias sets the "alias" field. +func (u *EndpointUpsert) SetAlias(v string) *EndpointUpsert { + u.Set(endpoint.FieldAlias, v) + return u +} + +// UpdateAlias sets the "alias" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateAlias() *EndpointUpsert { + u.SetExcluded(endpoint.FieldAlias) + return u +} + +// ClearAlias clears the value of the "alias" field. +func (u *EndpointUpsert) ClearAlias() *EndpointUpsert { + u.SetNull(endpoint.FieldAlias) + return u +} + +// SetStatus sets the "status" field. +func (u *EndpointUpsert) SetStatus(v endpoint.Status) *EndpointUpsert { + u.Set(endpoint.FieldStatus, v) + return u +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateStatus() *EndpointUpsert { + u.SetExcluded(endpoint.FieldStatus) + return u +} + +// SetLastSeenAt sets the "last_seen_at" field. +func (u *EndpointUpsert) SetLastSeenAt(v time.Time) *EndpointUpsert { + u.Set(endpoint.FieldLastSeenAt, v) + return u +} + +// UpdateLastSeenAt sets the "last_seen_at" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateLastSeenAt() *EndpointUpsert { + u.SetExcluded(endpoint.FieldLastSeenAt) + return u +} + +// ClearLastSeenAt clears the value of the "last_seen_at" field. +func (u *EndpointUpsert) ClearLastSeenAt() *EndpointUpsert { + u.SetNull(endpoint.FieldLastSeenAt) + return u +} + +// SetCreatedAt sets the "created_at" field. +func (u *EndpointUpsert) SetCreatedAt(v time.Time) *EndpointUpsert { + u.Set(endpoint.FieldCreatedAt, v) + return u +} + +// UpdateCreatedAt sets the "created_at" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateCreatedAt() *EndpointUpsert { + u.SetExcluded(endpoint.FieldCreatedAt) + return u +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *EndpointUpsert) SetUpdatedAt(v time.Time) *EndpointUpsert { + u.Set(endpoint.FieldUpdatedAt, v) + return u +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *EndpointUpsert) UpdateUpdatedAt() *EndpointUpsert { + u.SetExcluded(endpoint.FieldUpdatedAt) + return u +} + +// UpdateNewValues updates the mutable fields using the new values that were set on create except the ID field. +// Using this option is equivalent to using: +// +// client.Endpoint.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// sql.ResolveWith(func(u *sql.UpdateSet) { +// u.SetIgnore(endpoint.FieldID) +// }), +// ). +// Exec(ctx) +func (u *EndpointUpsertOne) UpdateNewValues() *EndpointUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + if _, exists := u.create.mutation.ID(); exists { + s.SetIgnore(endpoint.FieldID) + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.Endpoint.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *EndpointUpsertOne) Ignore() *EndpointUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *EndpointUpsertOne) DoNothing() *EndpointUpsertOne { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the EndpointCreate.OnConflict +// documentation for more info. +func (u *EndpointUpsertOne) Update(set func(*EndpointUpsert)) *EndpointUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&EndpointUpsert{UpdateSet: update}) + })) + return u +} + +// SetUserID sets the "user_id" field. +func (u *EndpointUpsertOne) SetUserID(v uuid.UUID) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetUserID(v) + }) +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateUserID() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateUserID() + }) +} + +// SetMachineID sets the "machine_id" field. +func (u *EndpointUpsertOne) SetMachineID(v uuid.UUID) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetMachineID(v) + }) +} + +// UpdateMachineID sets the "machine_id" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateMachineID() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateMachineID() + }) +} + +// SetDeviceName sets the "device_name" field. +func (u *EndpointUpsertOne) SetDeviceName(v string) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetDeviceName(v) + }) +} + +// UpdateDeviceName sets the "device_name" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateDeviceName() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateDeviceName() + }) +} + +// SetPlatform sets the "platform" field. +func (u *EndpointUpsertOne) SetPlatform(v endpoint.Platform) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetPlatform(v) + }) +} + +// UpdatePlatform sets the "platform" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdatePlatform() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdatePlatform() + }) +} + +// SetOsVersion sets the "os_version" field. +func (u *EndpointUpsertOne) SetOsVersion(v string) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetOsVersion(v) + }) +} + +// UpdateOsVersion sets the "os_version" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateOsVersion() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateOsVersion() + }) +} + +// SetArch sets the "arch" field. +func (u *EndpointUpsertOne) SetArch(v string) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetArch(v) + }) +} + +// UpdateArch sets the "arch" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateArch() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateArch() + }) +} + +// SetClientVersion sets the "client_version" field. +func (u *EndpointUpsertOne) SetClientVersion(v string) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetClientVersion(v) + }) +} + +// UpdateClientVersion sets the "client_version" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateClientVersion() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateClientVersion() + }) +} + +// SetProtocolVersion sets the "protocol_version" field. +func (u *EndpointUpsertOne) SetProtocolVersion(v int) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetProtocolVersion(v) + }) +} + +// AddProtocolVersion adds v to the "protocol_version" field. +func (u *EndpointUpsertOne) AddProtocolVersion(v int) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.AddProtocolVersion(v) + }) +} + +// UpdateProtocolVersion sets the "protocol_version" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateProtocolVersion() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateProtocolVersion() + }) +} + +// SetAlias sets the "alias" field. +func (u *EndpointUpsertOne) SetAlias(v string) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetAlias(v) + }) +} + +// UpdateAlias sets the "alias" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateAlias() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateAlias() + }) +} + +// ClearAlias clears the value of the "alias" field. +func (u *EndpointUpsertOne) ClearAlias() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.ClearAlias() + }) +} + +// SetStatus sets the "status" field. +func (u *EndpointUpsertOne) SetStatus(v endpoint.Status) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateStatus() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateStatus() + }) +} + +// SetLastSeenAt sets the "last_seen_at" field. +func (u *EndpointUpsertOne) SetLastSeenAt(v time.Time) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetLastSeenAt(v) + }) +} + +// UpdateLastSeenAt sets the "last_seen_at" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateLastSeenAt() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateLastSeenAt() + }) +} + +// ClearLastSeenAt clears the value of the "last_seen_at" field. +func (u *EndpointUpsertOne) ClearLastSeenAt() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.ClearLastSeenAt() + }) +} + +// SetCreatedAt sets the "created_at" field. +func (u *EndpointUpsertOne) SetCreatedAt(v time.Time) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetCreatedAt(v) + }) +} + +// UpdateCreatedAt sets the "created_at" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateCreatedAt() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateCreatedAt() + }) +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *EndpointUpsertOne) SetUpdatedAt(v time.Time) *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *EndpointUpsertOne) UpdateUpdatedAt() *EndpointUpsertOne { + return u.Update(func(s *EndpointUpsert) { + s.UpdateUpdatedAt() + }) +} + +// Exec executes the query. +func (u *EndpointUpsertOne) Exec(ctx context.Context) error { + if len(u.create.conflict) == 0 { + return errors.New("db: missing options for EndpointCreate.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *EndpointUpsertOne) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} + +// Exec executes the UPSERT query and returns the inserted/updated ID. +func (u *EndpointUpsertOne) ID(ctx context.Context) (id uuid.UUID, err error) { + if u.create.driver.Dialect() == dialect.MySQL { + // In case of "ON CONFLICT", there is no way to get back non-numeric ID + // fields from the database since MySQL does not support the RETURNING clause. + return id, errors.New("db: EndpointUpsertOne.ID is not supported by MySQL driver. Use EndpointUpsertOne.Exec instead") + } + node, err := u.create.Save(ctx) + if err != nil { + return id, err + } + return node.ID, nil +} + +// IDX is like ID, but panics if an error occurs. +func (u *EndpointUpsertOne) IDX(ctx context.Context) uuid.UUID { + id, err := u.ID(ctx) + if err != nil { + panic(err) + } + return id +} + +// EndpointCreateBulk is the builder for creating many Endpoint entities in bulk. +type EndpointCreateBulk struct { + config + err error + builders []*EndpointCreate + conflict []sql.ConflictOption +} + +// Save creates the Endpoint entities in the database. +func (_c *EndpointCreateBulk) Save(ctx context.Context) ([]*Endpoint, error) { + if _c.err != nil { + return nil, _c.err + } + specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) + nodes := make([]*Endpoint, len(_c.builders)) + mutators := make([]Mutator, len(_c.builders)) + for i := range _c.builders { + func(i int, root context.Context) { + builder := _c.builders[i] + builder.defaults() + var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { + mutation, ok := m.(*EndpointMutation) + if !ok { + return nil, fmt.Errorf("unexpected mutation type %T", m) + } + if err := builder.check(); err != nil { + return nil, err + } + builder.mutation = mutation + var err error + nodes[i], specs[i] = builder.createSpec() + if i < len(mutators)-1 { + _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation) + } else { + spec := &sqlgraph.BatchCreateSpec{Nodes: specs} + spec.OnConflict = _c.conflict + // Invoke the actual operation on the latest mutation in the chain. + if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + } + } + if err != nil { + return nil, err + } + mutation.id = &nodes[i].ID + mutation.done = true + return nodes[i], nil + }) + for i := len(builder.hooks) - 1; i >= 0; i-- { + mut = builder.hooks[i](mut) + } + mutators[i] = mut + }(i, ctx) + } + if len(mutators) > 0 { + if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil { + return nil, err + } + } + return nodes, nil +} + +// SaveX is like Save, but panics if an error occurs. +func (_c *EndpointCreateBulk) SaveX(ctx context.Context) []*Endpoint { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *EndpointCreateBulk) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *EndpointCreateBulk) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.Endpoint.CreateBulk(builders...). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.EndpointUpsert) { +// SetUserID(v+v). +// }). +// Exec(ctx) +func (_c *EndpointCreateBulk) OnConflict(opts ...sql.ConflictOption) *EndpointUpsertBulk { + _c.conflict = opts + return &EndpointUpsertBulk{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.Endpoint.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *EndpointCreateBulk) OnConflictColumns(columns ...string) *EndpointUpsertBulk { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &EndpointUpsertBulk{ + create: _c, + } +} + +// EndpointUpsertBulk is the builder for "upsert"-ing +// a bulk of Endpoint nodes. +type EndpointUpsertBulk struct { + create *EndpointCreateBulk +} + +// UpdateNewValues updates the mutable fields using the new values that +// were set on create. Using this option is equivalent to using: +// +// client.Endpoint.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// sql.ResolveWith(func(u *sql.UpdateSet) { +// u.SetIgnore(endpoint.FieldID) +// }), +// ). +// Exec(ctx) +func (u *EndpointUpsertBulk) UpdateNewValues() *EndpointUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + for _, b := range u.create.builders { + if _, exists := b.mutation.ID(); exists { + s.SetIgnore(endpoint.FieldID) + } + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.Endpoint.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *EndpointUpsertBulk) Ignore() *EndpointUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *EndpointUpsertBulk) DoNothing() *EndpointUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the EndpointCreateBulk.OnConflict +// documentation for more info. +func (u *EndpointUpsertBulk) Update(set func(*EndpointUpsert)) *EndpointUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&EndpointUpsert{UpdateSet: update}) + })) + return u +} + +// SetUserID sets the "user_id" field. +func (u *EndpointUpsertBulk) SetUserID(v uuid.UUID) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetUserID(v) + }) +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateUserID() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateUserID() + }) +} + +// SetMachineID sets the "machine_id" field. +func (u *EndpointUpsertBulk) SetMachineID(v uuid.UUID) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetMachineID(v) + }) +} + +// UpdateMachineID sets the "machine_id" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateMachineID() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateMachineID() + }) +} + +// SetDeviceName sets the "device_name" field. +func (u *EndpointUpsertBulk) SetDeviceName(v string) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetDeviceName(v) + }) +} + +// UpdateDeviceName sets the "device_name" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateDeviceName() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateDeviceName() + }) +} + +// SetPlatform sets the "platform" field. +func (u *EndpointUpsertBulk) SetPlatform(v endpoint.Platform) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetPlatform(v) + }) +} + +// UpdatePlatform sets the "platform" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdatePlatform() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdatePlatform() + }) +} + +// SetOsVersion sets the "os_version" field. +func (u *EndpointUpsertBulk) SetOsVersion(v string) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetOsVersion(v) + }) +} + +// UpdateOsVersion sets the "os_version" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateOsVersion() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateOsVersion() + }) +} + +// SetArch sets the "arch" field. +func (u *EndpointUpsertBulk) SetArch(v string) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetArch(v) + }) +} + +// UpdateArch sets the "arch" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateArch() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateArch() + }) +} + +// SetClientVersion sets the "client_version" field. +func (u *EndpointUpsertBulk) SetClientVersion(v string) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetClientVersion(v) + }) +} + +// UpdateClientVersion sets the "client_version" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateClientVersion() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateClientVersion() + }) +} + +// SetProtocolVersion sets the "protocol_version" field. +func (u *EndpointUpsertBulk) SetProtocolVersion(v int) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetProtocolVersion(v) + }) +} + +// AddProtocolVersion adds v to the "protocol_version" field. +func (u *EndpointUpsertBulk) AddProtocolVersion(v int) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.AddProtocolVersion(v) + }) +} + +// UpdateProtocolVersion sets the "protocol_version" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateProtocolVersion() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateProtocolVersion() + }) +} + +// SetAlias sets the "alias" field. +func (u *EndpointUpsertBulk) SetAlias(v string) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetAlias(v) + }) +} + +// UpdateAlias sets the "alias" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateAlias() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateAlias() + }) +} + +// ClearAlias clears the value of the "alias" field. +func (u *EndpointUpsertBulk) ClearAlias() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.ClearAlias() + }) +} + +// SetStatus sets the "status" field. +func (u *EndpointUpsertBulk) SetStatus(v endpoint.Status) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateStatus() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateStatus() + }) +} + +// SetLastSeenAt sets the "last_seen_at" field. +func (u *EndpointUpsertBulk) SetLastSeenAt(v time.Time) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetLastSeenAt(v) + }) +} + +// UpdateLastSeenAt sets the "last_seen_at" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateLastSeenAt() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateLastSeenAt() + }) +} + +// ClearLastSeenAt clears the value of the "last_seen_at" field. +func (u *EndpointUpsertBulk) ClearLastSeenAt() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.ClearLastSeenAt() + }) +} + +// SetCreatedAt sets the "created_at" field. +func (u *EndpointUpsertBulk) SetCreatedAt(v time.Time) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetCreatedAt(v) + }) +} + +// UpdateCreatedAt sets the "created_at" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateCreatedAt() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateCreatedAt() + }) +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *EndpointUpsertBulk) SetUpdatedAt(v time.Time) *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *EndpointUpsertBulk) UpdateUpdatedAt() *EndpointUpsertBulk { + return u.Update(func(s *EndpointUpsert) { + s.UpdateUpdatedAt() + }) +} + +// Exec executes the query. +func (u *EndpointUpsertBulk) Exec(ctx context.Context) error { + if u.create.err != nil { + return u.create.err + } + for i, b := range u.create.builders { + if len(b.conflict) != 0 { + return fmt.Errorf("db: OnConflict was set for builder %d. Set it on the EndpointCreateBulk instead", i) + } + } + if len(u.create.conflict) == 0 { + return errors.New("db: missing options for EndpointCreateBulk.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *EndpointUpsertBulk) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/db/endpoint_delete.go b/backend/db/endpoint_delete.go new file mode 100644 index 000000000..41287627d --- /dev/null +++ b/backend/db/endpoint_delete.go @@ -0,0 +1,88 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" + "github.com/chaitin/MonkeyCode/backend/db/predicate" +) + +// EndpointDelete is the builder for deleting a Endpoint entity. +type EndpointDelete struct { + config + hooks []Hook + mutation *EndpointMutation +} + +// Where appends a list predicates to the EndpointDelete builder. +func (_d *EndpointDelete) Where(ps ...predicate.Endpoint) *EndpointDelete { + _d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query and returns how many vertices were deleted. +func (_d *EndpointDelete) Exec(ctx context.Context) (int, error) { + return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks) +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *EndpointDelete) ExecX(ctx context.Context) int { + n, err := _d.Exec(ctx) + if err != nil { + panic(err) + } + return n +} + +func (_d *EndpointDelete) sqlExec(ctx context.Context) (int, error) { + _spec := sqlgraph.NewDeleteSpec(endpoint.Table, sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID)) + if ps := _d.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec) + if err != nil && sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + _d.mutation.done = true + return affected, err +} + +// EndpointDeleteOne is the builder for deleting a single Endpoint entity. +type EndpointDeleteOne struct { + _d *EndpointDelete +} + +// Where appends a list predicates to the EndpointDelete builder. +func (_d *EndpointDeleteOne) Where(ps ...predicate.Endpoint) *EndpointDeleteOne { + _d._d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query. +func (_d *EndpointDeleteOne) Exec(ctx context.Context) error { + n, err := _d._d.Exec(ctx) + switch { + case err != nil: + return err + case n == 0: + return &NotFoundError{endpoint.Label} + default: + return nil + } +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *EndpointDeleteOne) ExecX(ctx context.Context) { + if err := _d.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/db/endpoint_query.go b/backend/db/endpoint_query.go new file mode 100644 index 000000000..0699def6b --- /dev/null +++ b/backend/db/endpoint_query.go @@ -0,0 +1,657 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + "fmt" + "math" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" + "github.com/chaitin/MonkeyCode/backend/db/predicate" + "github.com/chaitin/MonkeyCode/backend/db/user" + "github.com/google/uuid" +) + +// EndpointQuery is the builder for querying Endpoint entities. +type EndpointQuery struct { + config + ctx *QueryContext + order []endpoint.OrderOption + inters []Interceptor + predicates []predicate.Endpoint + withUser *UserQuery + modifiers []func(*sql.Selector) + // intermediate query (i.e. traversal path). + sql *sql.Selector + path func(context.Context) (*sql.Selector, error) +} + +// Where adds a new predicate for the EndpointQuery builder. +func (_q *EndpointQuery) Where(ps ...predicate.Endpoint) *EndpointQuery { + _q.predicates = append(_q.predicates, ps...) + return _q +} + +// Limit the number of records to be returned by this query. +func (_q *EndpointQuery) Limit(limit int) *EndpointQuery { + _q.ctx.Limit = &limit + return _q +} + +// Offset to start from. +func (_q *EndpointQuery) Offset(offset int) *EndpointQuery { + _q.ctx.Offset = &offset + return _q +} + +// Unique configures the query builder to filter duplicate records on query. +// By default, unique is set to true, and can be disabled using this method. +func (_q *EndpointQuery) Unique(unique bool) *EndpointQuery { + _q.ctx.Unique = &unique + return _q +} + +// Order specifies how the records should be ordered. +func (_q *EndpointQuery) Order(o ...endpoint.OrderOption) *EndpointQuery { + _q.order = append(_q.order, o...) + return _q +} + +// QueryUser chains the current query on the "user" edge. +func (_q *EndpointQuery) QueryUser() *UserQuery { + query := (&UserClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(endpoint.Table, endpoint.FieldID, selector), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, endpoint.UserTable, endpoint.UserColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// First returns the first Endpoint entity from the query. +// Returns a *NotFoundError when no Endpoint was found. +func (_q *EndpointQuery) First(ctx context.Context) (*Endpoint, error) { + nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst)) + if err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, &NotFoundError{endpoint.Label} + } + return nodes[0], nil +} + +// FirstX is like First, but panics if an error occurs. +func (_q *EndpointQuery) FirstX(ctx context.Context) *Endpoint { + node, err := _q.First(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return node +} + +// FirstID returns the first Endpoint ID from the query. +// Returns a *NotFoundError when no Endpoint ID was found. +func (_q *EndpointQuery) FirstID(ctx context.Context) (id uuid.UUID, err error) { + var ids []uuid.UUID + if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil { + return + } + if len(ids) == 0 { + err = &NotFoundError{endpoint.Label} + return + } + return ids[0], nil +} + +// FirstIDX is like FirstID, but panics if an error occurs. +func (_q *EndpointQuery) FirstIDX(ctx context.Context) uuid.UUID { + id, err := _q.FirstID(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return id +} + +// Only returns a single Endpoint entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one Endpoint entity is found. +// Returns a *NotFoundError when no Endpoint entities are found. +func (_q *EndpointQuery) Only(ctx context.Context) (*Endpoint, error) { + nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly)) + if err != nil { + return nil, err + } + switch len(nodes) { + case 1: + return nodes[0], nil + case 0: + return nil, &NotFoundError{endpoint.Label} + default: + return nil, &NotSingularError{endpoint.Label} + } +} + +// OnlyX is like Only, but panics if an error occurs. +func (_q *EndpointQuery) OnlyX(ctx context.Context) *Endpoint { + node, err := _q.Only(ctx) + if err != nil { + panic(err) + } + return node +} + +// OnlyID is like Only, but returns the only Endpoint ID in the query. +// Returns a *NotSingularError when more than one Endpoint ID is found. +// Returns a *NotFoundError when no entities are found. +func (_q *EndpointQuery) OnlyID(ctx context.Context) (id uuid.UUID, err error) { + var ids []uuid.UUID + if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil { + return + } + switch len(ids) { + case 1: + id = ids[0] + case 0: + err = &NotFoundError{endpoint.Label} + default: + err = &NotSingularError{endpoint.Label} + } + return +} + +// OnlyIDX is like OnlyID, but panics if an error occurs. +func (_q *EndpointQuery) OnlyIDX(ctx context.Context) uuid.UUID { + id, err := _q.OnlyID(ctx) + if err != nil { + panic(err) + } + return id +} + +// All executes the query and returns a list of Endpoints. +func (_q *EndpointQuery) All(ctx context.Context) ([]*Endpoint, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + qr := querierAll[[]*Endpoint, *EndpointQuery]() + return withInterceptors[[]*Endpoint](ctx, _q, qr, _q.inters) +} + +// AllX is like All, but panics if an error occurs. +func (_q *EndpointQuery) AllX(ctx context.Context) []*Endpoint { + nodes, err := _q.All(ctx) + if err != nil { + panic(err) + } + return nodes +} + +// IDs executes the query and returns a list of Endpoint IDs. +func (_q *EndpointQuery) IDs(ctx context.Context) (ids []uuid.UUID, err error) { + if _q.ctx.Unique == nil && _q.path != nil { + _q.Unique(true) + } + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs) + if err = _q.Select(endpoint.FieldID).Scan(ctx, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// IDsX is like IDs, but panics if an error occurs. +func (_q *EndpointQuery) IDsX(ctx context.Context) []uuid.UUID { + ids, err := _q.IDs(ctx) + if err != nil { + panic(err) + } + return ids +} + +// Count returns the count of the given query. +func (_q *EndpointQuery) Count(ctx context.Context) (int, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount) + if err := _q.prepareQuery(ctx); err != nil { + return 0, err + } + return withInterceptors[int](ctx, _q, querierCount[*EndpointQuery](), _q.inters) +} + +// CountX is like Count, but panics if an error occurs. +func (_q *EndpointQuery) CountX(ctx context.Context) int { + count, err := _q.Count(ctx) + if err != nil { + panic(err) + } + return count +} + +// Exist returns true if the query has elements in the graph. +func (_q *EndpointQuery) Exist(ctx context.Context) (bool, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist) + switch _, err := _q.FirstID(ctx); { + case IsNotFound(err): + return false, nil + case err != nil: + return false, fmt.Errorf("db: check existence: %w", err) + default: + return true, nil + } +} + +// ExistX is like Exist, but panics if an error occurs. +func (_q *EndpointQuery) ExistX(ctx context.Context) bool { + exist, err := _q.Exist(ctx) + if err != nil { + panic(err) + } + return exist +} + +// Clone returns a duplicate of the EndpointQuery builder, including all associated steps. It can be +// used to prepare common query builders and use them differently after the clone is made. +func (_q *EndpointQuery) Clone() *EndpointQuery { + if _q == nil { + return nil + } + return &EndpointQuery{ + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]endpoint.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.Endpoint{}, _q.predicates...), + withUser: _q.withUser.Clone(), + // clone intermediate query. + sql: _q.sql.Clone(), + path: _q.path, + modifiers: append([]func(*sql.Selector){}, _q.modifiers...), + } +} + +// WithUser tells the query-builder to eager-load the nodes that are connected to +// the "user" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *EndpointQuery) WithUser(opts ...func(*UserQuery)) *EndpointQuery { + query := (&UserClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withUser = query + return _q +} + +// GroupBy is used to group vertices by one or more fields/columns. +// It is often used with aggregate functions, like: count, max, mean, min, sum. +// +// Example: +// +// var v []struct { +// UserID uuid.UUID `json:"user_id,omitempty"` +// Count int `json:"count,omitempty"` +// } +// +// client.Endpoint.Query(). +// GroupBy(endpoint.FieldUserID). +// Aggregate(db.Count()). +// Scan(ctx, &v) +func (_q *EndpointQuery) GroupBy(field string, fields ...string) *EndpointGroupBy { + _q.ctx.Fields = append([]string{field}, fields...) + grbuild := &EndpointGroupBy{build: _q} + grbuild.flds = &_q.ctx.Fields + grbuild.label = endpoint.Label + grbuild.scan = grbuild.Scan + return grbuild +} + +// Select allows the selection one or more fields/columns for the given query, +// instead of selecting all fields in the entity. +// +// Example: +// +// var v []struct { +// UserID uuid.UUID `json:"user_id,omitempty"` +// } +// +// client.Endpoint.Query(). +// Select(endpoint.FieldUserID). +// Scan(ctx, &v) +func (_q *EndpointQuery) Select(fields ...string) *EndpointSelect { + _q.ctx.Fields = append(_q.ctx.Fields, fields...) + sbuild := &EndpointSelect{EndpointQuery: _q} + sbuild.label = endpoint.Label + sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan + return sbuild +} + +// Aggregate returns a EndpointSelect configured with the given aggregations. +func (_q *EndpointQuery) Aggregate(fns ...AggregateFunc) *EndpointSelect { + return _q.Select().Aggregate(fns...) +} + +func (_q *EndpointQuery) prepareQuery(ctx context.Context) error { + for _, inter := range _q.inters { + if inter == nil { + return fmt.Errorf("db: uninitialized interceptor (forgotten import db/runtime?)") + } + if trv, ok := inter.(Traverser); ok { + if err := trv.Traverse(ctx, _q); err != nil { + return err + } + } + } + for _, f := range _q.ctx.Fields { + if !endpoint.ValidColumn(f) { + return &ValidationError{Name: f, err: fmt.Errorf("db: invalid field %q for query", f)} + } + } + if _q.path != nil { + prev, err := _q.path(ctx) + if err != nil { + return err + } + _q.sql = prev + } + return nil +} + +func (_q *EndpointQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Endpoint, error) { + var ( + nodes = []*Endpoint{} + _spec = _q.querySpec() + loadedTypes = [1]bool{ + _q.withUser != nil, + } + ) + _spec.ScanValues = func(columns []string) ([]any, error) { + return (*Endpoint).scanValues(nil, columns) + } + _spec.Assign = func(columns []string, values []any) error { + node := &Endpoint{config: _q.config} + nodes = append(nodes, node) + node.Edges.loadedTypes = loadedTypes + return node.assignValues(columns, values) + } + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + for i := range hooks { + hooks[i](ctx, _spec) + } + if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil { + return nil, err + } + if len(nodes) == 0 { + return nodes, nil + } + if query := _q.withUser; query != nil { + if err := _q.loadUser(ctx, query, nodes, nil, + func(n *Endpoint, e *User) { n.Edges.User = e }); err != nil { + return nil, err + } + } + return nodes, nil +} + +func (_q *EndpointQuery) loadUser(ctx context.Context, query *UserQuery, nodes []*Endpoint, init func(*Endpoint), assign func(*Endpoint, *User)) error { + ids := make([]uuid.UUID, 0, len(nodes)) + nodeids := make(map[uuid.UUID][]*Endpoint) + for i := range nodes { + fk := nodes[i].UserID + if _, ok := nodeids[fk]; !ok { + ids = append(ids, fk) + } + nodeids[fk] = append(nodeids[fk], nodes[i]) + } + if len(ids) == 0 { + return nil + } + query.Where(user.IDIn(ids...)) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + nodes, ok := nodeids[n.ID] + if !ok { + return fmt.Errorf(`unexpected foreign-key "user_id" returned %v`, n.ID) + } + for i := range nodes { + assign(nodes[i], n) + } + } + return nil +} + +func (_q *EndpointQuery) sqlCount(ctx context.Context) (int, error) { + _spec := _q.querySpec() + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + _spec.Node.Columns = _q.ctx.Fields + if len(_q.ctx.Fields) > 0 { + _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique + } + return sqlgraph.CountNodes(ctx, _q.driver, _spec) +} + +func (_q *EndpointQuery) querySpec() *sqlgraph.QuerySpec { + _spec := sqlgraph.NewQuerySpec(endpoint.Table, endpoint.Columns, sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID)) + _spec.From = _q.sql + if unique := _q.ctx.Unique; unique != nil { + _spec.Unique = *unique + } else if _q.path != nil { + _spec.Unique = true + } + if fields := _q.ctx.Fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, endpoint.FieldID) + for i := range fields { + if fields[i] != endpoint.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, fields[i]) + } + } + if _q.withUser != nil { + _spec.Node.AddColumnOnce(endpoint.FieldUserID) + } + } + if ps := _q.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if limit := _q.ctx.Limit; limit != nil { + _spec.Limit = *limit + } + if offset := _q.ctx.Offset; offset != nil { + _spec.Offset = *offset + } + if ps := _q.order; len(ps) > 0 { + _spec.Order = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + return _spec +} + +func (_q *EndpointQuery) sqlQuery(ctx context.Context) *sql.Selector { + builder := sql.Dialect(_q.driver.Dialect()) + t1 := builder.Table(endpoint.Table) + columns := _q.ctx.Fields + if len(columns) == 0 { + columns = endpoint.Columns + } + selector := builder.Select(t1.Columns(columns...)...).From(t1) + if _q.sql != nil { + selector = _q.sql + selector.Select(selector.Columns(columns...)...) + } + if _q.ctx.Unique != nil && *_q.ctx.Unique { + selector.Distinct() + } + for _, m := range _q.modifiers { + m(selector) + } + for _, p := range _q.predicates { + p(selector) + } + for _, p := range _q.order { + p(selector) + } + if offset := _q.ctx.Offset; offset != nil { + // limit is mandatory for offset clause. We start + // with default value, and override it below if needed. + selector.Offset(*offset).Limit(math.MaxInt32) + } + if limit := _q.ctx.Limit; limit != nil { + selector.Limit(*limit) + } + return selector +} + +// ForUpdate locks the selected rows against concurrent updates, and prevent them from being +// updated, deleted or "selected ... for update" by other sessions, until the transaction is +// either committed or rolled-back. +func (_q *EndpointQuery) ForUpdate(opts ...sql.LockOption) *EndpointQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForUpdate(opts...) + }) + return _q +} + +// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock +// on any rows that are read. Other sessions can read the rows, but cannot modify them +// until your transaction commits. +func (_q *EndpointQuery) ForShare(opts ...sql.LockOption) *EndpointQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + +// Modify adds a query modifier for attaching custom logic to queries. +func (_q *EndpointQuery) Modify(modifiers ...func(s *sql.Selector)) *EndpointSelect { + _q.modifiers = append(_q.modifiers, modifiers...) + return _q.Select() +} + +// EndpointGroupBy is the group-by builder for Endpoint entities. +type EndpointGroupBy struct { + selector + build *EndpointQuery +} + +// Aggregate adds the given aggregation functions to the group-by query. +func (_g *EndpointGroupBy) Aggregate(fns ...AggregateFunc) *EndpointGroupBy { + _g.fns = append(_g.fns, fns...) + return _g +} + +// Scan applies the selector query and scans the result into the given value. +func (_g *EndpointGroupBy) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy) + if err := _g.build.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*EndpointQuery, *EndpointGroupBy](ctx, _g.build, _g, _g.build.inters, v) +} + +func (_g *EndpointGroupBy) sqlScan(ctx context.Context, root *EndpointQuery, v any) error { + selector := root.sqlQuery(ctx).Select() + aggregation := make([]string, 0, len(_g.fns)) + for _, fn := range _g.fns { + aggregation = append(aggregation, fn(selector)) + } + if len(selector.SelectedColumns()) == 0 { + columns := make([]string, 0, len(*_g.flds)+len(_g.fns)) + for _, f := range *_g.flds { + columns = append(columns, selector.C(f)) + } + columns = append(columns, aggregation...) + selector.Select(columns...) + } + selector.GroupBy(selector.Columns(*_g.flds...)...) + if err := selector.Err(); err != nil { + return err + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _g.build.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} + +// EndpointSelect is the builder for selecting fields of Endpoint entities. +type EndpointSelect struct { + *EndpointQuery + selector +} + +// Aggregate adds the given aggregation functions to the selector query. +func (_s *EndpointSelect) Aggregate(fns ...AggregateFunc) *EndpointSelect { + _s.fns = append(_s.fns, fns...) + return _s +} + +// Scan applies the selector query and scans the result into the given value. +func (_s *EndpointSelect) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect) + if err := _s.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*EndpointQuery, *EndpointSelect](ctx, _s.EndpointQuery, _s, _s.inters, v) +} + +func (_s *EndpointSelect) sqlScan(ctx context.Context, root *EndpointQuery, v any) error { + selector := root.sqlQuery(ctx) + aggregation := make([]string, 0, len(_s.fns)) + for _, fn := range _s.fns { + aggregation = append(aggregation, fn(selector)) + } + switch n := len(*_s.selector.flds); { + case n == 0 && len(aggregation) > 0: + selector.Select(aggregation...) + case n != 0 && len(aggregation) > 0: + selector.AppendSelect(aggregation...) + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _s.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} + +// Modify adds a query modifier for attaching custom logic to queries. +func (_s *EndpointSelect) Modify(modifiers ...func(s *sql.Selector)) *EndpointSelect { + _s.modifiers = append(_s.modifiers, modifiers...) + return _s +} diff --git a/backend/db/endpoint_update.go b/backend/db/endpoint_update.go new file mode 100644 index 000000000..44594728f --- /dev/null +++ b/backend/db/endpoint_update.go @@ -0,0 +1,820 @@ +// Code generated by ent, DO NOT EDIT. + +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" + "github.com/chaitin/MonkeyCode/backend/db/predicate" + "github.com/chaitin/MonkeyCode/backend/db/user" + "github.com/google/uuid" +) + +// EndpointUpdate is the builder for updating Endpoint entities. +type EndpointUpdate struct { + config + hooks []Hook + mutation *EndpointMutation + modifiers []func(*sql.UpdateBuilder) +} + +// Where appends a list predicates to the EndpointUpdate builder. +func (_u *EndpointUpdate) Where(ps ...predicate.Endpoint) *EndpointUpdate { + _u.mutation.Where(ps...) + return _u +} + +// SetUserID sets the "user_id" field. +func (_u *EndpointUpdate) SetUserID(v uuid.UUID) *EndpointUpdate { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillableUserID(v *uuid.UUID) *EndpointUpdate { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetMachineID sets the "machine_id" field. +func (_u *EndpointUpdate) SetMachineID(v uuid.UUID) *EndpointUpdate { + _u.mutation.SetMachineID(v) + return _u +} + +// SetNillableMachineID sets the "machine_id" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillableMachineID(v *uuid.UUID) *EndpointUpdate { + if v != nil { + _u.SetMachineID(*v) + } + return _u +} + +// SetDeviceName sets the "device_name" field. +func (_u *EndpointUpdate) SetDeviceName(v string) *EndpointUpdate { + _u.mutation.SetDeviceName(v) + return _u +} + +// SetNillableDeviceName sets the "device_name" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillableDeviceName(v *string) *EndpointUpdate { + if v != nil { + _u.SetDeviceName(*v) + } + return _u +} + +// SetPlatform sets the "platform" field. +func (_u *EndpointUpdate) SetPlatform(v endpoint.Platform) *EndpointUpdate { + _u.mutation.SetPlatform(v) + return _u +} + +// SetNillablePlatform sets the "platform" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillablePlatform(v *endpoint.Platform) *EndpointUpdate { + if v != nil { + _u.SetPlatform(*v) + } + return _u +} + +// SetOsVersion sets the "os_version" field. +func (_u *EndpointUpdate) SetOsVersion(v string) *EndpointUpdate { + _u.mutation.SetOsVersion(v) + return _u +} + +// SetNillableOsVersion sets the "os_version" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillableOsVersion(v *string) *EndpointUpdate { + if v != nil { + _u.SetOsVersion(*v) + } + return _u +} + +// SetArch sets the "arch" field. +func (_u *EndpointUpdate) SetArch(v string) *EndpointUpdate { + _u.mutation.SetArch(v) + return _u +} + +// SetNillableArch sets the "arch" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillableArch(v *string) *EndpointUpdate { + if v != nil { + _u.SetArch(*v) + } + return _u +} + +// SetClientVersion sets the "client_version" field. +func (_u *EndpointUpdate) SetClientVersion(v string) *EndpointUpdate { + _u.mutation.SetClientVersion(v) + return _u +} + +// SetNillableClientVersion sets the "client_version" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillableClientVersion(v *string) *EndpointUpdate { + if v != nil { + _u.SetClientVersion(*v) + } + return _u +} + +// SetProtocolVersion sets the "protocol_version" field. +func (_u *EndpointUpdate) SetProtocolVersion(v int) *EndpointUpdate { + _u.mutation.ResetProtocolVersion() + _u.mutation.SetProtocolVersion(v) + return _u +} + +// SetNillableProtocolVersion sets the "protocol_version" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillableProtocolVersion(v *int) *EndpointUpdate { + if v != nil { + _u.SetProtocolVersion(*v) + } + return _u +} + +// AddProtocolVersion adds value to the "protocol_version" field. +func (_u *EndpointUpdate) AddProtocolVersion(v int) *EndpointUpdate { + _u.mutation.AddProtocolVersion(v) + return _u +} + +// SetAlias sets the "alias" field. +func (_u *EndpointUpdate) SetAlias(v string) *EndpointUpdate { + _u.mutation.SetAlias(v) + return _u +} + +// SetNillableAlias sets the "alias" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillableAlias(v *string) *EndpointUpdate { + if v != nil { + _u.SetAlias(*v) + } + return _u +} + +// ClearAlias clears the value of the "alias" field. +func (_u *EndpointUpdate) ClearAlias() *EndpointUpdate { + _u.mutation.ClearAlias() + return _u +} + +// SetStatus sets the "status" field. +func (_u *EndpointUpdate) SetStatus(v endpoint.Status) *EndpointUpdate { + _u.mutation.SetStatus(v) + return _u +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillableStatus(v *endpoint.Status) *EndpointUpdate { + if v != nil { + _u.SetStatus(*v) + } + return _u +} + +// SetLastSeenAt sets the "last_seen_at" field. +func (_u *EndpointUpdate) SetLastSeenAt(v time.Time) *EndpointUpdate { + _u.mutation.SetLastSeenAt(v) + return _u +} + +// SetNillableLastSeenAt sets the "last_seen_at" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillableLastSeenAt(v *time.Time) *EndpointUpdate { + if v != nil { + _u.SetLastSeenAt(*v) + } + return _u +} + +// ClearLastSeenAt clears the value of the "last_seen_at" field. +func (_u *EndpointUpdate) ClearLastSeenAt() *EndpointUpdate { + _u.mutation.ClearLastSeenAt() + return _u +} + +// SetCreatedAt sets the "created_at" field. +func (_u *EndpointUpdate) SetCreatedAt(v time.Time) *EndpointUpdate { + _u.mutation.SetCreatedAt(v) + return _u +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_u *EndpointUpdate) SetNillableCreatedAt(v *time.Time) *EndpointUpdate { + if v != nil { + _u.SetCreatedAt(*v) + } + return _u +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *EndpointUpdate) SetUpdatedAt(v time.Time) *EndpointUpdate { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// SetUser sets the "user" edge to the User entity. +func (_u *EndpointUpdate) SetUser(v *User) *EndpointUpdate { + return _u.SetUserID(v.ID) +} + +// Mutation returns the EndpointMutation object of the builder. +func (_u *EndpointUpdate) Mutation() *EndpointMutation { + return _u.mutation +} + +// ClearUser clears the "user" edge to the User entity. +func (_u *EndpointUpdate) ClearUser() *EndpointUpdate { + _u.mutation.ClearUser() + return _u +} + +// Save executes the query and returns the number of nodes affected by the update operation. +func (_u *EndpointUpdate) Save(ctx context.Context) (int, error) { + _u.defaults() + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *EndpointUpdate) SaveX(ctx context.Context) int { + affected, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return affected +} + +// Exec executes the query. +func (_u *EndpointUpdate) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *EndpointUpdate) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_u *EndpointUpdate) defaults() { + if _, ok := _u.mutation.UpdatedAt(); !ok { + v := endpoint.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *EndpointUpdate) check() error { + if v, ok := _u.mutation.DeviceName(); ok { + if err := endpoint.DeviceNameValidator(v); err != nil { + return &ValidationError{Name: "device_name", err: fmt.Errorf(`db: validator failed for field "Endpoint.device_name": %w`, err)} + } + } + if v, ok := _u.mutation.Platform(); ok { + if err := endpoint.PlatformValidator(v); err != nil { + return &ValidationError{Name: "platform", err: fmt.Errorf(`db: validator failed for field "Endpoint.platform": %w`, err)} + } + } + if v, ok := _u.mutation.Status(); ok { + if err := endpoint.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`db: validator failed for field "Endpoint.status": %w`, err)} + } + } + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`db: clearing a required unique edge "Endpoint.user"`) + } + return nil +} + +// Modify adds a statement modifier for attaching custom logic to the UPDATE statement. +func (_u *EndpointUpdate) Modify(modifiers ...func(u *sql.UpdateBuilder)) *EndpointUpdate { + _u.modifiers = append(_u.modifiers, modifiers...) + return _u +} + +func (_u *EndpointUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(endpoint.Table, endpoint.Columns, sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID)) + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.MachineID(); ok { + _spec.SetField(endpoint.FieldMachineID, field.TypeUUID, value) + } + if value, ok := _u.mutation.DeviceName(); ok { + _spec.SetField(endpoint.FieldDeviceName, field.TypeString, value) + } + if value, ok := _u.mutation.Platform(); ok { + _spec.SetField(endpoint.FieldPlatform, field.TypeEnum, value) + } + if value, ok := _u.mutation.OsVersion(); ok { + _spec.SetField(endpoint.FieldOsVersion, field.TypeString, value) + } + if value, ok := _u.mutation.Arch(); ok { + _spec.SetField(endpoint.FieldArch, field.TypeString, value) + } + if value, ok := _u.mutation.ClientVersion(); ok { + _spec.SetField(endpoint.FieldClientVersion, field.TypeString, value) + } + if value, ok := _u.mutation.ProtocolVersion(); ok { + _spec.SetField(endpoint.FieldProtocolVersion, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedProtocolVersion(); ok { + _spec.AddField(endpoint.FieldProtocolVersion, field.TypeInt, value) + } + if value, ok := _u.mutation.Alias(); ok { + _spec.SetField(endpoint.FieldAlias, field.TypeString, value) + } + if _u.mutation.AliasCleared() { + _spec.ClearField(endpoint.FieldAlias, field.TypeString) + } + if value, ok := _u.mutation.Status(); ok { + _spec.SetField(endpoint.FieldStatus, field.TypeEnum, value) + } + if value, ok := _u.mutation.LastSeenAt(); ok { + _spec.SetField(endpoint.FieldLastSeenAt, field.TypeTime, value) + } + if _u.mutation.LastSeenAtCleared() { + _spec.ClearField(endpoint.FieldLastSeenAt, field.TypeTime) + } + if value, ok := _u.mutation.CreatedAt(); ok { + _spec.SetField(endpoint.FieldCreatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(endpoint.FieldUpdatedAt, field.TypeTime, value) + } + if _u.mutation.UserCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: endpoint.UserTable, + Columns: []string{endpoint.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: endpoint.UserTable, + Columns: []string{endpoint.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + _spec.AddModifiers(_u.modifiers...) + if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{endpoint.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return 0, err + } + _u.mutation.done = true + return _node, nil +} + +// EndpointUpdateOne is the builder for updating a single Endpoint entity. +type EndpointUpdateOne struct { + config + fields []string + hooks []Hook + mutation *EndpointMutation + modifiers []func(*sql.UpdateBuilder) +} + +// SetUserID sets the "user_id" field. +func (_u *EndpointUpdateOne) SetUserID(v uuid.UUID) *EndpointUpdateOne { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillableUserID(v *uuid.UUID) *EndpointUpdateOne { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetMachineID sets the "machine_id" field. +func (_u *EndpointUpdateOne) SetMachineID(v uuid.UUID) *EndpointUpdateOne { + _u.mutation.SetMachineID(v) + return _u +} + +// SetNillableMachineID sets the "machine_id" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillableMachineID(v *uuid.UUID) *EndpointUpdateOne { + if v != nil { + _u.SetMachineID(*v) + } + return _u +} + +// SetDeviceName sets the "device_name" field. +func (_u *EndpointUpdateOne) SetDeviceName(v string) *EndpointUpdateOne { + _u.mutation.SetDeviceName(v) + return _u +} + +// SetNillableDeviceName sets the "device_name" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillableDeviceName(v *string) *EndpointUpdateOne { + if v != nil { + _u.SetDeviceName(*v) + } + return _u +} + +// SetPlatform sets the "platform" field. +func (_u *EndpointUpdateOne) SetPlatform(v endpoint.Platform) *EndpointUpdateOne { + _u.mutation.SetPlatform(v) + return _u +} + +// SetNillablePlatform sets the "platform" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillablePlatform(v *endpoint.Platform) *EndpointUpdateOne { + if v != nil { + _u.SetPlatform(*v) + } + return _u +} + +// SetOsVersion sets the "os_version" field. +func (_u *EndpointUpdateOne) SetOsVersion(v string) *EndpointUpdateOne { + _u.mutation.SetOsVersion(v) + return _u +} + +// SetNillableOsVersion sets the "os_version" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillableOsVersion(v *string) *EndpointUpdateOne { + if v != nil { + _u.SetOsVersion(*v) + } + return _u +} + +// SetArch sets the "arch" field. +func (_u *EndpointUpdateOne) SetArch(v string) *EndpointUpdateOne { + _u.mutation.SetArch(v) + return _u +} + +// SetNillableArch sets the "arch" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillableArch(v *string) *EndpointUpdateOne { + if v != nil { + _u.SetArch(*v) + } + return _u +} + +// SetClientVersion sets the "client_version" field. +func (_u *EndpointUpdateOne) SetClientVersion(v string) *EndpointUpdateOne { + _u.mutation.SetClientVersion(v) + return _u +} + +// SetNillableClientVersion sets the "client_version" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillableClientVersion(v *string) *EndpointUpdateOne { + if v != nil { + _u.SetClientVersion(*v) + } + return _u +} + +// SetProtocolVersion sets the "protocol_version" field. +func (_u *EndpointUpdateOne) SetProtocolVersion(v int) *EndpointUpdateOne { + _u.mutation.ResetProtocolVersion() + _u.mutation.SetProtocolVersion(v) + return _u +} + +// SetNillableProtocolVersion sets the "protocol_version" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillableProtocolVersion(v *int) *EndpointUpdateOne { + if v != nil { + _u.SetProtocolVersion(*v) + } + return _u +} + +// AddProtocolVersion adds value to the "protocol_version" field. +func (_u *EndpointUpdateOne) AddProtocolVersion(v int) *EndpointUpdateOne { + _u.mutation.AddProtocolVersion(v) + return _u +} + +// SetAlias sets the "alias" field. +func (_u *EndpointUpdateOne) SetAlias(v string) *EndpointUpdateOne { + _u.mutation.SetAlias(v) + return _u +} + +// SetNillableAlias sets the "alias" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillableAlias(v *string) *EndpointUpdateOne { + if v != nil { + _u.SetAlias(*v) + } + return _u +} + +// ClearAlias clears the value of the "alias" field. +func (_u *EndpointUpdateOne) ClearAlias() *EndpointUpdateOne { + _u.mutation.ClearAlias() + return _u +} + +// SetStatus sets the "status" field. +func (_u *EndpointUpdateOne) SetStatus(v endpoint.Status) *EndpointUpdateOne { + _u.mutation.SetStatus(v) + return _u +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillableStatus(v *endpoint.Status) *EndpointUpdateOne { + if v != nil { + _u.SetStatus(*v) + } + return _u +} + +// SetLastSeenAt sets the "last_seen_at" field. +func (_u *EndpointUpdateOne) SetLastSeenAt(v time.Time) *EndpointUpdateOne { + _u.mutation.SetLastSeenAt(v) + return _u +} + +// SetNillableLastSeenAt sets the "last_seen_at" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillableLastSeenAt(v *time.Time) *EndpointUpdateOne { + if v != nil { + _u.SetLastSeenAt(*v) + } + return _u +} + +// ClearLastSeenAt clears the value of the "last_seen_at" field. +func (_u *EndpointUpdateOne) ClearLastSeenAt() *EndpointUpdateOne { + _u.mutation.ClearLastSeenAt() + return _u +} + +// SetCreatedAt sets the "created_at" field. +func (_u *EndpointUpdateOne) SetCreatedAt(v time.Time) *EndpointUpdateOne { + _u.mutation.SetCreatedAt(v) + return _u +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_u *EndpointUpdateOne) SetNillableCreatedAt(v *time.Time) *EndpointUpdateOne { + if v != nil { + _u.SetCreatedAt(*v) + } + return _u +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *EndpointUpdateOne) SetUpdatedAt(v time.Time) *EndpointUpdateOne { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// SetUser sets the "user" edge to the User entity. +func (_u *EndpointUpdateOne) SetUser(v *User) *EndpointUpdateOne { + return _u.SetUserID(v.ID) +} + +// Mutation returns the EndpointMutation object of the builder. +func (_u *EndpointUpdateOne) Mutation() *EndpointMutation { + return _u.mutation +} + +// ClearUser clears the "user" edge to the User entity. +func (_u *EndpointUpdateOne) ClearUser() *EndpointUpdateOne { + _u.mutation.ClearUser() + return _u +} + +// Where appends a list predicates to the EndpointUpdate builder. +func (_u *EndpointUpdateOne) Where(ps ...predicate.Endpoint) *EndpointUpdateOne { + _u.mutation.Where(ps...) + return _u +} + +// Select allows selecting one or more fields (columns) of the returned entity. +// The default is selecting all fields defined in the entity schema. +func (_u *EndpointUpdateOne) Select(field string, fields ...string) *EndpointUpdateOne { + _u.fields = append([]string{field}, fields...) + return _u +} + +// Save executes the query and returns the updated Endpoint entity. +func (_u *EndpointUpdateOne) Save(ctx context.Context) (*Endpoint, error) { + _u.defaults() + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *EndpointUpdateOne) SaveX(ctx context.Context) *Endpoint { + node, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return node +} + +// Exec executes the query on the entity. +func (_u *EndpointUpdateOne) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *EndpointUpdateOne) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_u *EndpointUpdateOne) defaults() { + if _, ok := _u.mutation.UpdatedAt(); !ok { + v := endpoint.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *EndpointUpdateOne) check() error { + if v, ok := _u.mutation.DeviceName(); ok { + if err := endpoint.DeviceNameValidator(v); err != nil { + return &ValidationError{Name: "device_name", err: fmt.Errorf(`db: validator failed for field "Endpoint.device_name": %w`, err)} + } + } + if v, ok := _u.mutation.Platform(); ok { + if err := endpoint.PlatformValidator(v); err != nil { + return &ValidationError{Name: "platform", err: fmt.Errorf(`db: validator failed for field "Endpoint.platform": %w`, err)} + } + } + if v, ok := _u.mutation.Status(); ok { + if err := endpoint.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`db: validator failed for field "Endpoint.status": %w`, err)} + } + } + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`db: clearing a required unique edge "Endpoint.user"`) + } + return nil +} + +// Modify adds a statement modifier for attaching custom logic to the UPDATE statement. +func (_u *EndpointUpdateOne) Modify(modifiers ...func(u *sql.UpdateBuilder)) *EndpointUpdateOne { + _u.modifiers = append(_u.modifiers, modifiers...) + return _u +} + +func (_u *EndpointUpdateOne) sqlSave(ctx context.Context) (_node *Endpoint, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(endpoint.Table, endpoint.Columns, sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID)) + id, ok := _u.mutation.ID() + if !ok { + return nil, &ValidationError{Name: "id", err: errors.New(`db: missing "Endpoint.id" for update`)} + } + _spec.Node.ID.Value = id + if fields := _u.fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, endpoint.FieldID) + for _, f := range fields { + if !endpoint.ValidColumn(f) { + return nil, &ValidationError{Name: f, err: fmt.Errorf("db: invalid field %q for query", f)} + } + if f != endpoint.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, f) + } + } + } + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.MachineID(); ok { + _spec.SetField(endpoint.FieldMachineID, field.TypeUUID, value) + } + if value, ok := _u.mutation.DeviceName(); ok { + _spec.SetField(endpoint.FieldDeviceName, field.TypeString, value) + } + if value, ok := _u.mutation.Platform(); ok { + _spec.SetField(endpoint.FieldPlatform, field.TypeEnum, value) + } + if value, ok := _u.mutation.OsVersion(); ok { + _spec.SetField(endpoint.FieldOsVersion, field.TypeString, value) + } + if value, ok := _u.mutation.Arch(); ok { + _spec.SetField(endpoint.FieldArch, field.TypeString, value) + } + if value, ok := _u.mutation.ClientVersion(); ok { + _spec.SetField(endpoint.FieldClientVersion, field.TypeString, value) + } + if value, ok := _u.mutation.ProtocolVersion(); ok { + _spec.SetField(endpoint.FieldProtocolVersion, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedProtocolVersion(); ok { + _spec.AddField(endpoint.FieldProtocolVersion, field.TypeInt, value) + } + if value, ok := _u.mutation.Alias(); ok { + _spec.SetField(endpoint.FieldAlias, field.TypeString, value) + } + if _u.mutation.AliasCleared() { + _spec.ClearField(endpoint.FieldAlias, field.TypeString) + } + if value, ok := _u.mutation.Status(); ok { + _spec.SetField(endpoint.FieldStatus, field.TypeEnum, value) + } + if value, ok := _u.mutation.LastSeenAt(); ok { + _spec.SetField(endpoint.FieldLastSeenAt, field.TypeTime, value) + } + if _u.mutation.LastSeenAtCleared() { + _spec.ClearField(endpoint.FieldLastSeenAt, field.TypeTime) + } + if value, ok := _u.mutation.CreatedAt(); ok { + _spec.SetField(endpoint.FieldCreatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(endpoint.FieldUpdatedAt, field.TypeTime, value) + } + if _u.mutation.UserCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: endpoint.UserTable, + Columns: []string{endpoint.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: endpoint.UserTable, + Columns: []string{endpoint.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + _spec.AddModifiers(_u.modifiers...) + _node = &Endpoint{config: _u.config} + _spec.Assign = _node.assignValues + _spec.ScanValues = _node.scanValues + if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{endpoint.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + _u.mutation.done = true + return _node, nil +} diff --git a/backend/db/ent.go b/backend/db/ent.go index 983c36150..ef79b4018 100644 --- a/backend/db/ent.go +++ b/backend/db/ent.go @@ -23,6 +23,7 @@ import ( "github.com/chaitin/MonkeyCode/backend/db/agentskillversion" "github.com/chaitin/MonkeyCode/backend/db/agentsyncjob" "github.com/chaitin/MonkeyCode/backend/db/audit" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" "github.com/chaitin/MonkeyCode/backend/db/gitbot" "github.com/chaitin/MonkeyCode/backend/db/gitbottask" "github.com/chaitin/MonkeyCode/backend/db/gitbotuser" @@ -137,6 +138,7 @@ func checkColumn(t, c string) error { agentskillversion.Table: agentskillversion.ValidColumn, agentsyncjob.Table: agentsyncjob.ValidColumn, audit.Table: audit.ValidColumn, + endpoint.Table: endpoint.ValidColumn, gitbot.Table: gitbot.ValidColumn, gitbottask.Table: gitbottask.ValidColumn, gitbotuser.Table: gitbotuser.ValidColumn, diff --git a/backend/db/hook/hook.go b/backend/db/hook/hook.go index 1ee877d7b..2a0473f37 100644 --- a/backend/db/hook/hook.go +++ b/backend/db/hook/hook.go @@ -141,6 +141,18 @@ func (f AuditFunc) Mutate(ctx context.Context, m db.Mutation) (db.Value, error) return nil, fmt.Errorf("unexpected mutation type %T. expect *db.AuditMutation", m) } +// The EndpointFunc type is an adapter to allow the use of ordinary +// function as Endpoint mutator. +type EndpointFunc func(context.Context, *db.EndpointMutation) (db.Value, error) + +// Mutate calls f(ctx, m). +func (f EndpointFunc) Mutate(ctx context.Context, m db.Mutation) (db.Value, error) { + if mv, ok := m.(*db.EndpointMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *db.EndpointMutation", m) +} + // The GitBotFunc type is an adapter to allow the use of ordinary // function as GitBot mutator. type GitBotFunc func(context.Context, *db.GitBotMutation) (db.Value, error) diff --git a/backend/db/intercept/intercept.go b/backend/db/intercept/intercept.go index dcc92c0b6..f134eab4a 100644 --- a/backend/db/intercept/intercept.go +++ b/backend/db/intercept/intercept.go @@ -19,6 +19,7 @@ import ( "github.com/chaitin/MonkeyCode/backend/db/agentskillversion" "github.com/chaitin/MonkeyCode/backend/db/agentsyncjob" "github.com/chaitin/MonkeyCode/backend/db/audit" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" "github.com/chaitin/MonkeyCode/backend/db/gitbot" "github.com/chaitin/MonkeyCode/backend/db/gitbottask" "github.com/chaitin/MonkeyCode/backend/db/gitbotuser" @@ -418,6 +419,33 @@ func (f TraverseAudit) Traverse(ctx context.Context, q db.Query) error { return fmt.Errorf("unexpected query type %T. expect *db.AuditQuery", q) } +// The EndpointFunc type is an adapter to allow the use of ordinary function as a Querier. +type EndpointFunc func(context.Context, *db.EndpointQuery) (db.Value, error) + +// Query calls f(ctx, q). +func (f EndpointFunc) Query(ctx context.Context, q db.Query) (db.Value, error) { + if q, ok := q.(*db.EndpointQuery); ok { + return f(ctx, q) + } + return nil, fmt.Errorf("unexpected query type %T. expect *db.EndpointQuery", q) +} + +// The TraverseEndpoint type is an adapter to allow the use of ordinary function as Traverser. +type TraverseEndpoint func(context.Context, *db.EndpointQuery) error + +// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline. +func (f TraverseEndpoint) Intercept(next db.Querier) db.Querier { + return next +} + +// Traverse calls f(ctx, q). +func (f TraverseEndpoint) Traverse(ctx context.Context, q db.Query) error { + if q, ok := q.(*db.EndpointQuery); ok { + return f(ctx, q) + } + return fmt.Errorf("unexpected query type %T. expect *db.EndpointQuery", q) +} + // The GitBotFunc type is an adapter to allow the use of ordinary function as a Querier. type GitBotFunc func(context.Context, *db.GitBotQuery) (db.Value, error) @@ -1604,6 +1632,8 @@ func NewQuery(q db.Query) (Query, error) { return &query[*db.AgentSyncJobQuery, predicate.AgentSyncJob, agentsyncjob.OrderOption]{typ: db.TypeAgentSyncJob, tq: q}, nil case *db.AuditQuery: return &query[*db.AuditQuery, predicate.Audit, audit.OrderOption]{typ: db.TypeAudit, tq: q}, nil + case *db.EndpointQuery: + return &query[*db.EndpointQuery, predicate.Endpoint, endpoint.OrderOption]{typ: db.TypeEndpoint, tq: q}, nil case *db.GitBotQuery: return &query[*db.GitBotQuery, predicate.GitBot, gitbot.OrderOption]{typ: db.TypeGitBot, tq: q}, nil case *db.GitBotTaskQuery: diff --git a/backend/db/migrate/schema.go b/backend/db/migrate/schema.go index e0b14cedf..e928656f6 100644 --- a/backend/db/migrate/schema.go +++ b/backend/db/migrate/schema.go @@ -405,6 +405,44 @@ var ( }, }, } + // EndpointsColumns holds the columns for the "endpoints" table. + EndpointsColumns = []*schema.Column{ + {Name: "id", Type: field.TypeUUID}, + {Name: "machine_id", Type: field.TypeUUID}, + {Name: "device_name", Type: field.TypeString}, + {Name: "platform", Type: field.TypeEnum, Enums: []string{"macos", "windows", "linux", "ios", "android"}}, + {Name: "os_version", Type: field.TypeString}, + {Name: "arch", Type: field.TypeString}, + {Name: "client_version", Type: field.TypeString}, + {Name: "protocol_version", Type: field.TypeInt, Default: 1}, + {Name: "alias", Type: field.TypeString, Nullable: true}, + {Name: "status", Type: field.TypeEnum, Enums: []string{"active", "revoked"}, Default: "active"}, + {Name: "last_seen_at", Type: field.TypeTime, Nullable: true}, + {Name: "created_at", Type: field.TypeTime}, + {Name: "updated_at", Type: field.TypeTime}, + {Name: "user_id", Type: field.TypeUUID}, + } + // EndpointsTable holds the schema information for the "endpoints" table. + EndpointsTable = &schema.Table{ + Name: "endpoints", + Columns: EndpointsColumns, + PrimaryKey: []*schema.Column{EndpointsColumns[0]}, + ForeignKeys: []*schema.ForeignKey{ + { + Symbol: "endpoints_users_endpoints", + Columns: []*schema.Column{EndpointsColumns[13]}, + RefColumns: []*schema.Column{UsersColumns[0]}, + OnDelete: schema.NoAction, + }, + }, + Indexes: []*schema.Index{ + { + Name: "endpoint_user_id_machine_id", + Unique: true, + Columns: []*schema.Column{EndpointsColumns[13], EndpointsColumns[1]}, + }, + }, + } // GitBotsColumns holds the columns for the "git_bots" table. GitBotsColumns = []*schema.Column{ {Name: "id", Type: field.TypeUUID}, @@ -1971,6 +2009,7 @@ var ( AgentSkillVersionsTable, AgentSyncJobsTable, AuditsTable, + EndpointsTable, GitBotsTable, GitBotTasksTable, GitBotUsersTable, @@ -2059,6 +2098,10 @@ func init() { AuditsTable.Annotation = &entsql.Annotation{ Table: "audits", } + EndpointsTable.ForeignKeys[0].RefTable = UsersTable + EndpointsTable.Annotation = &entsql.Annotation{ + Table: "endpoints", + } GitBotsTable.ForeignKeys[0].RefTable = HostsTable GitBotsTable.Annotation = &entsql.Annotation{ Table: "git_bots", diff --git a/backend/db/mutation.go b/backend/db/mutation.go index 973cb1cf4..d251e5cff 100644 --- a/backend/db/mutation.go +++ b/backend/db/mutation.go @@ -23,6 +23,7 @@ import ( "github.com/chaitin/MonkeyCode/backend/db/agentskillversion" "github.com/chaitin/MonkeyCode/backend/db/agentsyncjob" "github.com/chaitin/MonkeyCode/backend/db/audit" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" "github.com/chaitin/MonkeyCode/backend/db/gitbot" "github.com/chaitin/MonkeyCode/backend/db/gitbottask" "github.com/chaitin/MonkeyCode/backend/db/gitbotuser" @@ -91,6 +92,7 @@ const ( TypeAgentSkillVersion = "AgentSkillVersion" TypeAgentSyncJob = "AgentSyncJob" TypeAudit = "Audit" + TypeEndpoint = "Endpoint" TypeGitBot = "GitBot" TypeGitBotTask = "GitBotTask" TypeGitBotUser = "GitBotUser" @@ -10741,6 +10743,1117 @@ func (m *AuditMutation) ResetEdge(name string) error { return fmt.Errorf("unknown Audit edge %s", name) } +// EndpointMutation represents an operation that mutates the Endpoint nodes in the graph. +type EndpointMutation struct { + config + op Op + typ string + id *uuid.UUID + machine_id *uuid.UUID + device_name *string + platform *endpoint.Platform + os_version *string + arch *string + client_version *string + protocol_version *int + addprotocol_version *int + alias *string + status *endpoint.Status + last_seen_at *time.Time + created_at *time.Time + updated_at *time.Time + clearedFields map[string]struct{} + user *uuid.UUID + cleareduser bool + done bool + oldValue func(context.Context) (*Endpoint, error) + predicates []predicate.Endpoint +} + +var _ ent.Mutation = (*EndpointMutation)(nil) + +// endpointOption allows management of the mutation configuration using functional options. +type endpointOption func(*EndpointMutation) + +// newEndpointMutation creates new mutation for the Endpoint entity. +func newEndpointMutation(c config, op Op, opts ...endpointOption) *EndpointMutation { + m := &EndpointMutation{ + config: c, + op: op, + typ: TypeEndpoint, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withEndpointID sets the ID field of the mutation. +func withEndpointID(id uuid.UUID) endpointOption { + return func(m *EndpointMutation) { + var ( + err error + once sync.Once + value *Endpoint + ) + m.oldValue = func(ctx context.Context) (*Endpoint, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().Endpoint.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withEndpoint sets the old Endpoint of the mutation. +func withEndpoint(node *Endpoint) endpointOption { + return func(m *EndpointMutation) { + m.oldValue = func(context.Context) (*Endpoint, error) { + return node, nil + } + m.id = &node.ID + } +} + +// Client returns a new `ent.Client` from the mutation. If the mutation was +// executed in a transaction (ent.Tx), a transactional client is returned. +func (m EndpointMutation) Client() *Client { + client := &Client{config: m.config} + client.init() + return client +} + +// Tx returns an `ent.Tx` for mutations that were executed in transactions; +// it returns an error otherwise. +func (m EndpointMutation) Tx() (*Tx, error) { + if _, ok := m.driver.(*txDriver); !ok { + return nil, errors.New("db: mutation is not running in a transaction") + } + tx := &Tx{config: m.config} + tx.init() + return tx, nil +} + +// SetID sets the value of the id field. Note that this +// operation is only accepted on creation of Endpoint entities. +func (m *EndpointMutation) SetID(id uuid.UUID) { + m.id = &id +} + +// ID returns the ID value in the mutation. Note that the ID is only available +// if it was provided to the builder or after it was returned from the database. +func (m *EndpointMutation) ID() (id uuid.UUID, exists bool) { + if m.id == nil { + return + } + return *m.id, true +} + +// IDs queries the database and returns the entity ids that match the mutation's predicate. +// That means, if the mutation is applied within a transaction with an isolation level such +// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated +// or updated by the mutation. +func (m *EndpointMutation) IDs(ctx context.Context) ([]uuid.UUID, error) { + switch { + case m.op.Is(OpUpdateOne | OpDeleteOne): + id, exists := m.ID() + if exists { + return []uuid.UUID{id}, nil + } + fallthrough + case m.op.Is(OpUpdate | OpDelete): + return m.Client().Endpoint.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetUserID sets the "user_id" field. +func (m *EndpointMutation) SetUserID(u uuid.UUID) { + m.user = &u +} + +// UserID returns the value of the "user_id" field in the mutation. +func (m *EndpointMutation) UserID() (r uuid.UUID, exists bool) { + v := m.user + if v == nil { + return + } + return *v, true +} + +// OldUserID returns the old "user_id" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldUserID(ctx context.Context) (v uuid.UUID, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUserID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUserID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUserID: %w", err) + } + return oldValue.UserID, nil +} + +// ResetUserID resets all changes to the "user_id" field. +func (m *EndpointMutation) ResetUserID() { + m.user = nil +} + +// SetMachineID sets the "machine_id" field. +func (m *EndpointMutation) SetMachineID(u uuid.UUID) { + m.machine_id = &u +} + +// MachineID returns the value of the "machine_id" field in the mutation. +func (m *EndpointMutation) MachineID() (r uuid.UUID, exists bool) { + v := m.machine_id + if v == nil { + return + } + return *v, true +} + +// OldMachineID returns the old "machine_id" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldMachineID(ctx context.Context) (v uuid.UUID, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldMachineID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldMachineID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldMachineID: %w", err) + } + return oldValue.MachineID, nil +} + +// ResetMachineID resets all changes to the "machine_id" field. +func (m *EndpointMutation) ResetMachineID() { + m.machine_id = nil +} + +// SetDeviceName sets the "device_name" field. +func (m *EndpointMutation) SetDeviceName(s string) { + m.device_name = &s +} + +// DeviceName returns the value of the "device_name" field in the mutation. +func (m *EndpointMutation) DeviceName() (r string, exists bool) { + v := m.device_name + if v == nil { + return + } + return *v, true +} + +// OldDeviceName returns the old "device_name" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldDeviceName(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldDeviceName is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldDeviceName requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldDeviceName: %w", err) + } + return oldValue.DeviceName, nil +} + +// ResetDeviceName resets all changes to the "device_name" field. +func (m *EndpointMutation) ResetDeviceName() { + m.device_name = nil +} + +// SetPlatform sets the "platform" field. +func (m *EndpointMutation) SetPlatform(e endpoint.Platform) { + m.platform = &e +} + +// Platform returns the value of the "platform" field in the mutation. +func (m *EndpointMutation) Platform() (r endpoint.Platform, exists bool) { + v := m.platform + if v == nil { + return + } + return *v, true +} + +// OldPlatform returns the old "platform" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldPlatform(ctx context.Context) (v endpoint.Platform, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldPlatform is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldPlatform requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldPlatform: %w", err) + } + return oldValue.Platform, nil +} + +// ResetPlatform resets all changes to the "platform" field. +func (m *EndpointMutation) ResetPlatform() { + m.platform = nil +} + +// SetOsVersion sets the "os_version" field. +func (m *EndpointMutation) SetOsVersion(s string) { + m.os_version = &s +} + +// OsVersion returns the value of the "os_version" field in the mutation. +func (m *EndpointMutation) OsVersion() (r string, exists bool) { + v := m.os_version + if v == nil { + return + } + return *v, true +} + +// OldOsVersion returns the old "os_version" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldOsVersion(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldOsVersion is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldOsVersion requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldOsVersion: %w", err) + } + return oldValue.OsVersion, nil +} + +// ResetOsVersion resets all changes to the "os_version" field. +func (m *EndpointMutation) ResetOsVersion() { + m.os_version = nil +} + +// SetArch sets the "arch" field. +func (m *EndpointMutation) SetArch(s string) { + m.arch = &s +} + +// Arch returns the value of the "arch" field in the mutation. +func (m *EndpointMutation) Arch() (r string, exists bool) { + v := m.arch + if v == nil { + return + } + return *v, true +} + +// OldArch returns the old "arch" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldArch(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldArch is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldArch requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldArch: %w", err) + } + return oldValue.Arch, nil +} + +// ResetArch resets all changes to the "arch" field. +func (m *EndpointMutation) ResetArch() { + m.arch = nil +} + +// SetClientVersion sets the "client_version" field. +func (m *EndpointMutation) SetClientVersion(s string) { + m.client_version = &s +} + +// ClientVersion returns the value of the "client_version" field in the mutation. +func (m *EndpointMutation) ClientVersion() (r string, exists bool) { + v := m.client_version + if v == nil { + return + } + return *v, true +} + +// OldClientVersion returns the old "client_version" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldClientVersion(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldClientVersion is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldClientVersion requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldClientVersion: %w", err) + } + return oldValue.ClientVersion, nil +} + +// ResetClientVersion resets all changes to the "client_version" field. +func (m *EndpointMutation) ResetClientVersion() { + m.client_version = nil +} + +// SetProtocolVersion sets the "protocol_version" field. +func (m *EndpointMutation) SetProtocolVersion(i int) { + m.protocol_version = &i + m.addprotocol_version = nil +} + +// ProtocolVersion returns the value of the "protocol_version" field in the mutation. +func (m *EndpointMutation) ProtocolVersion() (r int, exists bool) { + v := m.protocol_version + if v == nil { + return + } + return *v, true +} + +// OldProtocolVersion returns the old "protocol_version" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldProtocolVersion(ctx context.Context) (v int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldProtocolVersion is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldProtocolVersion requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldProtocolVersion: %w", err) + } + return oldValue.ProtocolVersion, nil +} + +// AddProtocolVersion adds i to the "protocol_version" field. +func (m *EndpointMutation) AddProtocolVersion(i int) { + if m.addprotocol_version != nil { + *m.addprotocol_version += i + } else { + m.addprotocol_version = &i + } +} + +// AddedProtocolVersion returns the value that was added to the "protocol_version" field in this mutation. +func (m *EndpointMutation) AddedProtocolVersion() (r int, exists bool) { + v := m.addprotocol_version + if v == nil { + return + } + return *v, true +} + +// ResetProtocolVersion resets all changes to the "protocol_version" field. +func (m *EndpointMutation) ResetProtocolVersion() { + m.protocol_version = nil + m.addprotocol_version = nil +} + +// SetAlias sets the "alias" field. +func (m *EndpointMutation) SetAlias(s string) { + m.alias = &s +} + +// Alias returns the value of the "alias" field in the mutation. +func (m *EndpointMutation) Alias() (r string, exists bool) { + v := m.alias + if v == nil { + return + } + return *v, true +} + +// OldAlias returns the old "alias" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldAlias(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAlias is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAlias requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAlias: %w", err) + } + return oldValue.Alias, nil +} + +// ClearAlias clears the value of the "alias" field. +func (m *EndpointMutation) ClearAlias() { + m.alias = nil + m.clearedFields[endpoint.FieldAlias] = struct{}{} +} + +// AliasCleared returns if the "alias" field was cleared in this mutation. +func (m *EndpointMutation) AliasCleared() bool { + _, ok := m.clearedFields[endpoint.FieldAlias] + return ok +} + +// ResetAlias resets all changes to the "alias" field. +func (m *EndpointMutation) ResetAlias() { + m.alias = nil + delete(m.clearedFields, endpoint.FieldAlias) +} + +// SetStatus sets the "status" field. +func (m *EndpointMutation) SetStatus(e endpoint.Status) { + m.status = &e +} + +// Status returns the value of the "status" field in the mutation. +func (m *EndpointMutation) Status() (r endpoint.Status, exists bool) { + v := m.status + if v == nil { + return + } + return *v, true +} + +// OldStatus returns the old "status" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldStatus(ctx context.Context) (v endpoint.Status, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldStatus is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldStatus requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldStatus: %w", err) + } + return oldValue.Status, nil +} + +// ResetStatus resets all changes to the "status" field. +func (m *EndpointMutation) ResetStatus() { + m.status = nil +} + +// SetLastSeenAt sets the "last_seen_at" field. +func (m *EndpointMutation) SetLastSeenAt(t time.Time) { + m.last_seen_at = &t +} + +// LastSeenAt returns the value of the "last_seen_at" field in the mutation. +func (m *EndpointMutation) LastSeenAt() (r time.Time, exists bool) { + v := m.last_seen_at + if v == nil { + return + } + return *v, true +} + +// OldLastSeenAt returns the old "last_seen_at" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldLastSeenAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldLastSeenAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldLastSeenAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldLastSeenAt: %w", err) + } + return oldValue.LastSeenAt, nil +} + +// ClearLastSeenAt clears the value of the "last_seen_at" field. +func (m *EndpointMutation) ClearLastSeenAt() { + m.last_seen_at = nil + m.clearedFields[endpoint.FieldLastSeenAt] = struct{}{} +} + +// LastSeenAtCleared returns if the "last_seen_at" field was cleared in this mutation. +func (m *EndpointMutation) LastSeenAtCleared() bool { + _, ok := m.clearedFields[endpoint.FieldLastSeenAt] + return ok +} + +// ResetLastSeenAt resets all changes to the "last_seen_at" field. +func (m *EndpointMutation) ResetLastSeenAt() { + m.last_seen_at = nil + delete(m.clearedFields, endpoint.FieldLastSeenAt) +} + +// SetCreatedAt sets the "created_at" field. +func (m *EndpointMutation) SetCreatedAt(t time.Time) { + m.created_at = &t +} + +// CreatedAt returns the value of the "created_at" field in the mutation. +func (m *EndpointMutation) CreatedAt() (r time.Time, exists bool) { + v := m.created_at + if v == nil { + return + } + return *v, true +} + +// OldCreatedAt returns the old "created_at" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCreatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err) + } + return oldValue.CreatedAt, nil +} + +// ResetCreatedAt resets all changes to the "created_at" field. +func (m *EndpointMutation) ResetCreatedAt() { + m.created_at = nil +} + +// SetUpdatedAt sets the "updated_at" field. +func (m *EndpointMutation) SetUpdatedAt(t time.Time) { + m.updated_at = &t +} + +// UpdatedAt returns the value of the "updated_at" field in the mutation. +func (m *EndpointMutation) UpdatedAt() (r time.Time, exists bool) { + v := m.updated_at + if v == nil { + return + } + return *v, true +} + +// OldUpdatedAt returns the old "updated_at" field's value of the Endpoint entity. +// If the Endpoint object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *EndpointMutation) OldUpdatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpdatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpdatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpdatedAt: %w", err) + } + return oldValue.UpdatedAt, nil +} + +// ResetUpdatedAt resets all changes to the "updated_at" field. +func (m *EndpointMutation) ResetUpdatedAt() { + m.updated_at = nil +} + +// ClearUser clears the "user" edge to the User entity. +func (m *EndpointMutation) ClearUser() { + m.cleareduser = true + m.clearedFields[endpoint.FieldUserID] = struct{}{} +} + +// UserCleared reports if the "user" edge to the User entity was cleared. +func (m *EndpointMutation) UserCleared() bool { + return m.cleareduser +} + +// UserIDs returns the "user" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// UserID instead. It exists only for internal usage by the builders. +func (m *EndpointMutation) UserIDs() (ids []uuid.UUID) { + if id := m.user; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetUser resets all changes to the "user" edge. +func (m *EndpointMutation) ResetUser() { + m.user = nil + m.cleareduser = false +} + +// Where appends a list predicates to the EndpointMutation builder. +func (m *EndpointMutation) Where(ps ...predicate.Endpoint) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the EndpointMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *EndpointMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.Endpoint, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *EndpointMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *EndpointMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (Endpoint). +func (m *EndpointMutation) Type() string { + return m.typ +} + +// Fields returns all fields that were changed during this mutation. Note that in +// order to get all numeric fields that were incremented/decremented, call +// AddedFields(). +func (m *EndpointMutation) Fields() []string { + fields := make([]string, 0, 13) + if m.user != nil { + fields = append(fields, endpoint.FieldUserID) + } + if m.machine_id != nil { + fields = append(fields, endpoint.FieldMachineID) + } + if m.device_name != nil { + fields = append(fields, endpoint.FieldDeviceName) + } + if m.platform != nil { + fields = append(fields, endpoint.FieldPlatform) + } + if m.os_version != nil { + fields = append(fields, endpoint.FieldOsVersion) + } + if m.arch != nil { + fields = append(fields, endpoint.FieldArch) + } + if m.client_version != nil { + fields = append(fields, endpoint.FieldClientVersion) + } + if m.protocol_version != nil { + fields = append(fields, endpoint.FieldProtocolVersion) + } + if m.alias != nil { + fields = append(fields, endpoint.FieldAlias) + } + if m.status != nil { + fields = append(fields, endpoint.FieldStatus) + } + if m.last_seen_at != nil { + fields = append(fields, endpoint.FieldLastSeenAt) + } + if m.created_at != nil { + fields = append(fields, endpoint.FieldCreatedAt) + } + if m.updated_at != nil { + fields = append(fields, endpoint.FieldUpdatedAt) + } + return fields +} + +// Field returns the value of a field with the given name. The second boolean +// return value indicates that this field was not set, or was not defined in the +// schema. +func (m *EndpointMutation) Field(name string) (ent.Value, bool) { + switch name { + case endpoint.FieldUserID: + return m.UserID() + case endpoint.FieldMachineID: + return m.MachineID() + case endpoint.FieldDeviceName: + return m.DeviceName() + case endpoint.FieldPlatform: + return m.Platform() + case endpoint.FieldOsVersion: + return m.OsVersion() + case endpoint.FieldArch: + return m.Arch() + case endpoint.FieldClientVersion: + return m.ClientVersion() + case endpoint.FieldProtocolVersion: + return m.ProtocolVersion() + case endpoint.FieldAlias: + return m.Alias() + case endpoint.FieldStatus: + return m.Status() + case endpoint.FieldLastSeenAt: + return m.LastSeenAt() + case endpoint.FieldCreatedAt: + return m.CreatedAt() + case endpoint.FieldUpdatedAt: + return m.UpdatedAt() + } + return nil, false +} + +// OldField returns the old value of the field from the database. An error is +// returned if the mutation operation is not UpdateOne, or the query to the +// database failed. +func (m *EndpointMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case endpoint.FieldUserID: + return m.OldUserID(ctx) + case endpoint.FieldMachineID: + return m.OldMachineID(ctx) + case endpoint.FieldDeviceName: + return m.OldDeviceName(ctx) + case endpoint.FieldPlatform: + return m.OldPlatform(ctx) + case endpoint.FieldOsVersion: + return m.OldOsVersion(ctx) + case endpoint.FieldArch: + return m.OldArch(ctx) + case endpoint.FieldClientVersion: + return m.OldClientVersion(ctx) + case endpoint.FieldProtocolVersion: + return m.OldProtocolVersion(ctx) + case endpoint.FieldAlias: + return m.OldAlias(ctx) + case endpoint.FieldStatus: + return m.OldStatus(ctx) + case endpoint.FieldLastSeenAt: + return m.OldLastSeenAt(ctx) + case endpoint.FieldCreatedAt: + return m.OldCreatedAt(ctx) + case endpoint.FieldUpdatedAt: + return m.OldUpdatedAt(ctx) + } + return nil, fmt.Errorf("unknown Endpoint field %s", name) +} + +// SetField sets the value of a field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *EndpointMutation) SetField(name string, value ent.Value) error { + switch name { + case endpoint.FieldUserID: + v, ok := value.(uuid.UUID) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUserID(v) + return nil + case endpoint.FieldMachineID: + v, ok := value.(uuid.UUID) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetMachineID(v) + return nil + case endpoint.FieldDeviceName: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetDeviceName(v) + return nil + case endpoint.FieldPlatform: + v, ok := value.(endpoint.Platform) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetPlatform(v) + return nil + case endpoint.FieldOsVersion: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetOsVersion(v) + return nil + case endpoint.FieldArch: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetArch(v) + return nil + case endpoint.FieldClientVersion: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetClientVersion(v) + return nil + case endpoint.FieldProtocolVersion: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetProtocolVersion(v) + return nil + case endpoint.FieldAlias: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAlias(v) + return nil + case endpoint.FieldStatus: + v, ok := value.(endpoint.Status) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetStatus(v) + return nil + case endpoint.FieldLastSeenAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetLastSeenAt(v) + return nil + case endpoint.FieldCreatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCreatedAt(v) + return nil + case endpoint.FieldUpdatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpdatedAt(v) + return nil + } + return fmt.Errorf("unknown Endpoint field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *EndpointMutation) AddedFields() []string { + var fields []string + if m.addprotocol_version != nil { + fields = append(fields, endpoint.FieldProtocolVersion) + } + return fields +} + +// AddedField returns the numeric value that was incremented/decremented on a field +// with the given name. The second boolean return value indicates that this field +// was not set, or was not defined in the schema. +func (m *EndpointMutation) AddedField(name string) (ent.Value, bool) { + switch name { + case endpoint.FieldProtocolVersion: + return m.AddedProtocolVersion() + } + return nil, false +} + +// AddField adds the value to the field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *EndpointMutation) AddField(name string, value ent.Value) error { + switch name { + case endpoint.FieldProtocolVersion: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddProtocolVersion(v) + return nil + } + return fmt.Errorf("unknown Endpoint numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *EndpointMutation) ClearedFields() []string { + var fields []string + if m.FieldCleared(endpoint.FieldAlias) { + fields = append(fields, endpoint.FieldAlias) + } + if m.FieldCleared(endpoint.FieldLastSeenAt) { + fields = append(fields, endpoint.FieldLastSeenAt) + } + return fields +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *EndpointMutation) FieldCleared(name string) bool { + _, ok := m.clearedFields[name] + return ok +} + +// ClearField clears the value of the field with the given name. It returns an +// error if the field is not defined in the schema. +func (m *EndpointMutation) ClearField(name string) error { + switch name { + case endpoint.FieldAlias: + m.ClearAlias() + return nil + case endpoint.FieldLastSeenAt: + m.ClearLastSeenAt() + return nil + } + return fmt.Errorf("unknown Endpoint nullable field %s", name) +} + +// ResetField resets all changes in the mutation for the field with the given name. +// It returns an error if the field is not defined in the schema. +func (m *EndpointMutation) ResetField(name string) error { + switch name { + case endpoint.FieldUserID: + m.ResetUserID() + return nil + case endpoint.FieldMachineID: + m.ResetMachineID() + return nil + case endpoint.FieldDeviceName: + m.ResetDeviceName() + return nil + case endpoint.FieldPlatform: + m.ResetPlatform() + return nil + case endpoint.FieldOsVersion: + m.ResetOsVersion() + return nil + case endpoint.FieldArch: + m.ResetArch() + return nil + case endpoint.FieldClientVersion: + m.ResetClientVersion() + return nil + case endpoint.FieldProtocolVersion: + m.ResetProtocolVersion() + return nil + case endpoint.FieldAlias: + m.ResetAlias() + return nil + case endpoint.FieldStatus: + m.ResetStatus() + return nil + case endpoint.FieldLastSeenAt: + m.ResetLastSeenAt() + return nil + case endpoint.FieldCreatedAt: + m.ResetCreatedAt() + return nil + case endpoint.FieldUpdatedAt: + m.ResetUpdatedAt() + return nil + } + return fmt.Errorf("unknown Endpoint field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *EndpointMutation) AddedEdges() []string { + edges := make([]string, 0, 1) + if m.user != nil { + edges = append(edges, endpoint.EdgeUser) + } + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *EndpointMutation) AddedIDs(name string) []ent.Value { + switch name { + case endpoint.EdgeUser: + if id := m.user; id != nil { + return []ent.Value{*id} + } + } + return nil +} + +// RemovedEdges returns all edge names that were removed in this mutation. +func (m *EndpointMutation) RemovedEdges() []string { + edges := make([]string, 0, 1) + return edges +} + +// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with +// the given name in this mutation. +func (m *EndpointMutation) RemovedIDs(name string) []ent.Value { + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *EndpointMutation) ClearedEdges() []string { + edges := make([]string, 0, 1) + if m.cleareduser { + edges = append(edges, endpoint.EdgeUser) + } + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *EndpointMutation) EdgeCleared(name string) bool { + switch name { + case endpoint.EdgeUser: + return m.cleareduser + } + return false +} + +// ClearEdge clears the value of the edge with the given name. It returns an error +// if that edge is not defined in the schema. +func (m *EndpointMutation) ClearEdge(name string) error { + switch name { + case endpoint.EdgeUser: + m.ClearUser() + return nil + } + return fmt.Errorf("unknown Endpoint unique edge %s", name) +} + +// ResetEdge resets all changes to the edge with the given name in this mutation. +// It returns an error if the edge is not defined in the schema. +func (m *EndpointMutation) ResetEdge(name string) error { + switch name { + case endpoint.EdgeUser: + m.ResetUser() + return nil + } + return fmt.Errorf("unknown Endpoint edge %s", name) +} + // GitBotMutation represents an operation that mutates the GitBot nodes in the graph. type GitBotMutation struct { config @@ -51612,6 +52725,9 @@ type UserMutation struct { mcp_upstreams map[uuid.UUID]struct{} removedmcp_upstreams map[uuid.UUID]struct{} clearedmcp_upstreams bool + endpoints map[uuid.UUID]struct{} + removedendpoints map[uuid.UUID]struct{} + clearedendpoints bool team_members map[uuid.UUID]struct{} removedteam_members map[uuid.UUID]struct{} clearedteam_members bool @@ -53163,6 +54279,60 @@ func (m *UserMutation) ResetMcpUpstreams() { m.removedmcp_upstreams = nil } +// AddEndpointIDs adds the "endpoints" edge to the Endpoint entity by ids. +func (m *UserMutation) AddEndpointIDs(ids ...uuid.UUID) { + if m.endpoints == nil { + m.endpoints = make(map[uuid.UUID]struct{}) + } + for i := range ids { + m.endpoints[ids[i]] = struct{}{} + } +} + +// ClearEndpoints clears the "endpoints" edge to the Endpoint entity. +func (m *UserMutation) ClearEndpoints() { + m.clearedendpoints = true +} + +// EndpointsCleared reports if the "endpoints" edge to the Endpoint entity was cleared. +func (m *UserMutation) EndpointsCleared() bool { + return m.clearedendpoints +} + +// RemoveEndpointIDs removes the "endpoints" edge to the Endpoint entity by IDs. +func (m *UserMutation) RemoveEndpointIDs(ids ...uuid.UUID) { + if m.removedendpoints == nil { + m.removedendpoints = make(map[uuid.UUID]struct{}) + } + for i := range ids { + delete(m.endpoints, ids[i]) + m.removedendpoints[ids[i]] = struct{}{} + } +} + +// RemovedEndpoints returns the removed IDs of the "endpoints" edge to the Endpoint entity. +func (m *UserMutation) RemovedEndpointsIDs() (ids []uuid.UUID) { + for id := range m.removedendpoints { + ids = append(ids, id) + } + return +} + +// EndpointsIDs returns the "endpoints" edge IDs in the mutation. +func (m *UserMutation) EndpointsIDs() (ids []uuid.UUID) { + for id := range m.endpoints { + ids = append(ids, id) + } + return +} + +// ResetEndpoints resets all changes to the "endpoints" edge. +func (m *UserMutation) ResetEndpoints() { + m.endpoints = nil + m.clearedendpoints = false + m.removedendpoints = nil +} + // AddTeamMemberIDs adds the "team_members" edge to the TeamMember entity by ids. func (m *UserMutation) AddTeamMemberIDs(ids ...uuid.UUID) { if m.team_members == nil { @@ -53661,7 +54831,7 @@ func (m *UserMutation) ResetField(name string) error { // AddedEdges returns all edge names that were set/added in this mutation. func (m *UserMutation) AddedEdges() []string { - edges := make([]string, 0, 21) + edges := make([]string, 0, 22) if m.identities != nil { edges = append(edges, user.EdgeIdentities) } @@ -53716,6 +54886,9 @@ func (m *UserMutation) AddedEdges() []string { if m.mcp_upstreams != nil { edges = append(edges, user.EdgeMcpUpstreams) } + if m.endpoints != nil { + edges = append(edges, user.EdgeEndpoints) + } if m.team_members != nil { edges = append(edges, user.EdgeTeamMembers) } @@ -53840,6 +55013,12 @@ func (m *UserMutation) AddedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case user.EdgeEndpoints: + ids := make([]ent.Value, 0, len(m.endpoints)) + for id := range m.endpoints { + ids = append(ids, id) + } + return ids case user.EdgeTeamMembers: ids := make([]ent.Value, 0, len(m.team_members)) for id := range m.team_members { @@ -53864,7 +55043,7 @@ func (m *UserMutation) AddedIDs(name string) []ent.Value { // RemovedEdges returns all edge names that were removed in this mutation. func (m *UserMutation) RemovedEdges() []string { - edges := make([]string, 0, 21) + edges := make([]string, 0, 22) if m.removedidentities != nil { edges = append(edges, user.EdgeIdentities) } @@ -53919,6 +55098,9 @@ func (m *UserMutation) RemovedEdges() []string { if m.removedmcp_upstreams != nil { edges = append(edges, user.EdgeMcpUpstreams) } + if m.removedendpoints != nil { + edges = append(edges, user.EdgeEndpoints) + } if m.removedteam_members != nil { edges = append(edges, user.EdgeTeamMembers) } @@ -54043,6 +55225,12 @@ func (m *UserMutation) RemovedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case user.EdgeEndpoints: + ids := make([]ent.Value, 0, len(m.removedendpoints)) + for id := range m.removedendpoints { + ids = append(ids, id) + } + return ids case user.EdgeTeamMembers: ids := make([]ent.Value, 0, len(m.removedteam_members)) for id := range m.removedteam_members { @@ -54067,7 +55255,7 @@ func (m *UserMutation) RemovedIDs(name string) []ent.Value { // ClearedEdges returns all edge names that were cleared in this mutation. func (m *UserMutation) ClearedEdges() []string { - edges := make([]string, 0, 21) + edges := make([]string, 0, 22) if m.clearedidentities { edges = append(edges, user.EdgeIdentities) } @@ -54122,6 +55310,9 @@ func (m *UserMutation) ClearedEdges() []string { if m.clearedmcp_upstreams { edges = append(edges, user.EdgeMcpUpstreams) } + if m.clearedendpoints { + edges = append(edges, user.EdgeEndpoints) + } if m.clearedteam_members { edges = append(edges, user.EdgeTeamMembers) } @@ -54174,6 +55365,8 @@ func (m *UserMutation) EdgeCleared(name string) bool { return m.clearedgit_bots case user.EdgeMcpUpstreams: return m.clearedmcp_upstreams + case user.EdgeEndpoints: + return m.clearedendpoints case user.EdgeTeamMembers: return m.clearedteam_members case user.EdgeTeamGroupMembers: @@ -54250,6 +55443,9 @@ func (m *UserMutation) ResetEdge(name string) error { case user.EdgeMcpUpstreams: m.ResetMcpUpstreams() return nil + case user.EdgeEndpoints: + m.ResetEndpoints() + return nil case user.EdgeTeamMembers: m.ResetTeamMembers() return nil diff --git a/backend/db/page.go b/backend/db/page.go index a6998d712..3b6b4cac6 100644 --- a/backend/db/page.go +++ b/backend/db/page.go @@ -165,6 +165,20 @@ func (_m *AuditQuery) Page(ctx context.Context, page, size int) ([]*Audit, *Page return rs, &PageInfo{HasNextPage: has, TotalCount: int64(cnt)}, nil } +func (_m *EndpointQuery) Page(ctx context.Context, page, size int) ([]*Endpoint, *PageInfo, error) { + cnt, err := _m.Count(ctx) + if err != nil { + return nil, nil, err + } + offset := size * (page - 1) + rs, err := _m.Offset(offset).Limit(size).All(ctx) + if err != nil { + return nil, nil, err + } + has := (page * size) < cnt + return rs, &PageInfo{HasNextPage: has, TotalCount: int64(cnt)}, nil +} + func (_m *GitBotQuery) Page(ctx context.Context, page, size int) ([]*GitBot, *PageInfo, error) { cnt, err := _m.Count(ctx) if err != nil { diff --git a/backend/db/predicate/predicate.go b/backend/db/predicate/predicate.go index 7fbaf7a3a..3e959d9c1 100644 --- a/backend/db/predicate/predicate.go +++ b/backend/db/predicate/predicate.go @@ -39,6 +39,9 @@ type AgentSyncJob func(*sql.Selector) // Audit is the predicate function for audit builders. type Audit func(*sql.Selector) +// Endpoint is the predicate function for endpoint builders. +type Endpoint func(*sql.Selector) + // GitBot is the predicate function for gitbot builders. type GitBot func(*sql.Selector) diff --git a/backend/db/runtime/runtime.go b/backend/db/runtime/runtime.go index e543561a2..5a297063b 100644 --- a/backend/db/runtime/runtime.go +++ b/backend/db/runtime/runtime.go @@ -17,6 +17,7 @@ import ( "github.com/chaitin/MonkeyCode/backend/db/agentskillversion" "github.com/chaitin/MonkeyCode/backend/db/agentsyncjob" "github.com/chaitin/MonkeyCode/backend/db/audit" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" "github.com/chaitin/MonkeyCode/backend/db/gitbot" "github.com/chaitin/MonkeyCode/backend/db/gitbottask" "github.com/chaitin/MonkeyCode/backend/db/gitbotuser" @@ -332,6 +333,30 @@ func init() { auditDescCreatedAt := auditFields[7].Descriptor() // audit.DefaultCreatedAt holds the default value on creation for the created_at field. audit.DefaultCreatedAt = auditDescCreatedAt.Default.(func() time.Time) + endpointFields := schema.Endpoint{}.Fields() + _ = endpointFields + // endpointDescDeviceName is the schema descriptor for device_name field. + endpointDescDeviceName := endpointFields[3].Descriptor() + // endpoint.DeviceNameValidator is a validator for the "device_name" field. It is called by the builders before save. + endpoint.DeviceNameValidator = endpointDescDeviceName.Validators[0].(func(string) error) + // endpointDescProtocolVersion is the schema descriptor for protocol_version field. + endpointDescProtocolVersion := endpointFields[8].Descriptor() + // endpoint.DefaultProtocolVersion holds the default value on creation for the protocol_version field. + endpoint.DefaultProtocolVersion = endpointDescProtocolVersion.Default.(int) + // endpointDescCreatedAt is the schema descriptor for created_at field. + endpointDescCreatedAt := endpointFields[12].Descriptor() + // endpoint.DefaultCreatedAt holds the default value on creation for the created_at field. + endpoint.DefaultCreatedAt = endpointDescCreatedAt.Default.(func() time.Time) + // endpointDescUpdatedAt is the schema descriptor for updated_at field. + endpointDescUpdatedAt := endpointFields[13].Descriptor() + // endpoint.DefaultUpdatedAt holds the default value on creation for the updated_at field. + endpoint.DefaultUpdatedAt = endpointDescUpdatedAt.Default.(func() time.Time) + // endpoint.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. + endpoint.UpdateDefaultUpdatedAt = endpointDescUpdatedAt.UpdateDefault.(func() time.Time) + // endpointDescID is the schema descriptor for id field. + endpointDescID := endpointFields[0].Descriptor() + // endpoint.DefaultID holds the default value on creation for the id field. + endpoint.DefaultID = endpointDescID.Default.(func() uuid.UUID) gitbotMixin := schema.GitBot{}.Mixin() gitbotMixinHooks0 := gitbotMixin[0].Hooks() gitbot.Hooks[0] = gitbotMixinHooks0[0] diff --git a/backend/db/tx.go b/backend/db/tx.go index a3704aa2e..794836d17 100644 --- a/backend/db/tx.go +++ b/backend/db/tx.go @@ -36,6 +36,8 @@ type Tx struct { AgentSyncJob *AgentSyncJobClient // Audit is the client for interacting with the Audit builders. Audit *AuditClient + // Endpoint is the client for interacting with the Endpoint builders. + Endpoint *EndpointClient // GitBot is the client for interacting with the GitBot builders. GitBot *GitBotClient // GitBotTask is the client for interacting with the GitBotTask builders. @@ -264,6 +266,7 @@ func (tx *Tx) init() { tx.AgentSkillVersion = NewAgentSkillVersionClient(tx.config) tx.AgentSyncJob = NewAgentSyncJobClient(tx.config) tx.Audit = NewAuditClient(tx.config) + tx.Endpoint = NewEndpointClient(tx.config) tx.GitBot = NewGitBotClient(tx.config) tx.GitBotTask = NewGitBotTaskClient(tx.config) tx.GitBotUser = NewGitBotUserClient(tx.config) diff --git a/backend/db/user.go b/backend/db/user.go index 36dc2187b..aacb44b20 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -86,6 +86,8 @@ type UserEdges struct { GitBots []*GitBot `json:"git_bots,omitempty"` // McpUpstreams holds the value of the mcp_upstreams edge. McpUpstreams []*MCPUpstream `json:"mcp_upstreams,omitempty"` + // Endpoints holds the value of the endpoints edge. + Endpoints []*Endpoint `json:"endpoints,omitempty"` // TeamMembers holds the value of the team_members edge. TeamMembers []*TeamMember `json:"team_members,omitempty"` // TeamGroupMembers holds the value of the team_group_members edge. @@ -94,7 +96,7 @@ type UserEdges struct { GitBotUsers []*GitBotUser `json:"git_bot_users,omitempty"` // loadedTypes holds the information for reporting if a // type was loaded (or requested) in eager-loading or not. - loadedTypes [21]bool + loadedTypes [22]bool } // IdentitiesOrErr returns the Identities value or an error if the edge @@ -259,10 +261,19 @@ func (e UserEdges) McpUpstreamsOrErr() ([]*MCPUpstream, error) { return nil, &NotLoadedError{edge: "mcp_upstreams"} } +// EndpointsOrErr returns the Endpoints value or an error if the edge +// was not loaded in eager-loading. +func (e UserEdges) EndpointsOrErr() ([]*Endpoint, error) { + if e.loadedTypes[18] { + return e.Endpoints, nil + } + return nil, &NotLoadedError{edge: "endpoints"} +} + // TeamMembersOrErr returns the TeamMembers value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) TeamMembersOrErr() ([]*TeamMember, error) { - if e.loadedTypes[18] { + if e.loadedTypes[19] { return e.TeamMembers, nil } return nil, &NotLoadedError{edge: "team_members"} @@ -271,7 +282,7 @@ func (e UserEdges) TeamMembersOrErr() ([]*TeamMember, error) { // TeamGroupMembersOrErr returns the TeamGroupMembers value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) TeamGroupMembersOrErr() ([]*TeamGroupMember, error) { - if e.loadedTypes[19] { + if e.loadedTypes[20] { return e.TeamGroupMembers, nil } return nil, &NotLoadedError{edge: "team_group_members"} @@ -280,7 +291,7 @@ func (e UserEdges) TeamGroupMembersOrErr() ([]*TeamGroupMember, error) { // GitBotUsersOrErr returns the GitBotUsers value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) GitBotUsersOrErr() ([]*GitBotUser, error) { - if e.loadedTypes[20] { + if e.loadedTypes[21] { return e.GitBotUsers, nil } return nil, &NotLoadedError{edge: "git_bot_users"} @@ -493,6 +504,11 @@ func (_m *User) QueryMcpUpstreams() *MCPUpstreamQuery { return NewUserClient(_m.config).QueryMcpUpstreams(_m) } +// QueryEndpoints queries the "endpoints" edge of the User entity. +func (_m *User) QueryEndpoints() *EndpointQuery { + return NewUserClient(_m.config).QueryEndpoints(_m) +} + // QueryTeamMembers queries the "team_members" edge of the User entity. func (_m *User) QueryTeamMembers() *TeamMemberQuery { return NewUserClient(_m.config).QueryTeamMembers(_m) diff --git a/backend/db/user/user.go b/backend/db/user/user.go index 0af0a2fca..210051bad 100644 --- a/backend/db/user/user.go +++ b/backend/db/user/user.go @@ -73,6 +73,8 @@ const ( EdgeGitBots = "git_bots" // EdgeMcpUpstreams holds the string denoting the mcp_upstreams edge name in mutations. EdgeMcpUpstreams = "mcp_upstreams" + // EdgeEndpoints holds the string denoting the endpoints edge name in mutations. + EdgeEndpoints = "endpoints" // EdgeTeamMembers holds the string denoting the team_members edge name in mutations. EdgeTeamMembers = "team_members" // EdgeTeamGroupMembers holds the string denoting the team_group_members edge name in mutations. @@ -201,6 +203,13 @@ const ( McpUpstreamsInverseTable = "mcp_upstreams" // McpUpstreamsColumn is the table column denoting the mcp_upstreams relation/edge. McpUpstreamsColumn = "user_id" + // EndpointsTable is the table that holds the endpoints relation/edge. + EndpointsTable = "endpoints" + // EndpointsInverseTable is the table name for the Endpoint entity. + // It exists in this package in order to avoid circular dependency with the "endpoint" package. + EndpointsInverseTable = "endpoints" + // EndpointsColumn is the table column denoting the endpoints relation/edge. + EndpointsColumn = "user_id" // TeamMembersTable is the table that holds the team_members relation/edge. TeamMembersTable = "team_members" // TeamMembersInverseTable is the table name for the TeamMember entity. @@ -592,6 +601,20 @@ func ByMcpUpstreams(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { } } +// ByEndpointsCount orders the results by endpoints count. +func ByEndpointsCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newEndpointsStep(), opts...) + } +} + +// ByEndpoints orders the results by endpoints terms. +func ByEndpoints(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newEndpointsStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} + // ByTeamMembersCount orders the results by team_members count. func ByTeamMembersCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { @@ -759,6 +782,13 @@ func newMcpUpstreamsStep() *sqlgraph.Step { sqlgraph.Edge(sqlgraph.O2M, false, McpUpstreamsTable, McpUpstreamsColumn), ) } +func newEndpointsStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(EndpointsInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, EndpointsTable, EndpointsColumn), + ) +} func newTeamMembersStep() *sqlgraph.Step { return sqlgraph.NewStep( sqlgraph.From(Table, FieldID), diff --git a/backend/db/user/where.go b/backend/db/user/where.go index 984ece12f..45f5b5c87 100644 --- a/backend/db/user/where.go +++ b/backend/db/user/where.go @@ -1131,6 +1131,29 @@ func HasMcpUpstreamsWith(preds ...predicate.MCPUpstream) predicate.User { }) } +// HasEndpoints applies the HasEdge predicate on the "endpoints" edge. +func HasEndpoints() predicate.User { + return predicate.User(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, EndpointsTable, EndpointsColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasEndpointsWith applies the HasEdge predicate on the "endpoints" edge with a given conditions (other predicates). +func HasEndpointsWith(preds ...predicate.Endpoint) predicate.User { + return predicate.User(func(s *sql.Selector) { + step := newEndpointsStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + // HasTeamMembers applies the HasEdge predicate on the "team_members" edge. func HasTeamMembers() predicate.User { return predicate.User(func(s *sql.Selector) { diff --git a/backend/db/user_create.go b/backend/db/user_create.go index f4a502c96..5d4e729f1 100644 --- a/backend/db/user_create.go +++ b/backend/db/user_create.go @@ -14,6 +14,7 @@ import ( "entgo.io/ent/schema/field" "github.com/chaitin/MonkeyCode/backend/consts" "github.com/chaitin/MonkeyCode/backend/db/audit" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" "github.com/chaitin/MonkeyCode/backend/db/gitbot" "github.com/chaitin/MonkeyCode/backend/db/gitbotuser" "github.com/chaitin/MonkeyCode/backend/db/gitidentity" @@ -443,6 +444,21 @@ func (_c *UserCreate) AddMcpUpstreams(v ...*MCPUpstream) *UserCreate { return _c.AddMcpUpstreamIDs(ids...) } +// AddEndpointIDs adds the "endpoints" edge to the Endpoint entity by IDs. +func (_c *UserCreate) AddEndpointIDs(ids ...uuid.UUID) *UserCreate { + _c.mutation.AddEndpointIDs(ids...) + return _c +} + +// AddEndpoints adds the "endpoints" edges to the Endpoint entity. +func (_c *UserCreate) AddEndpoints(v ...*Endpoint) *UserCreate { + ids := make([]uuid.UUID, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddEndpointIDs(ids...) +} + // AddTeamMemberIDs adds the "team_members" edge to the TeamMember entity by IDs. func (_c *UserCreate) AddTeamMemberIDs(ids ...uuid.UUID) *UserCreate { _c.mutation.AddTeamMemberIDs(ids...) @@ -954,6 +970,22 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { } _spec.Edges = append(_spec.Edges, edge) } + if nodes := _c.mutation.EndpointsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.EndpointsTable, + Columns: []string{user.EndpointsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges = append(_spec.Edges, edge) + } if nodes := _c.mutation.TeamMembersIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/backend/db/user_query.go b/backend/db/user_query.go index ec5367d11..2d1fb4050 100644 --- a/backend/db/user_query.go +++ b/backend/db/user_query.go @@ -14,6 +14,7 @@ import ( "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" "github.com/chaitin/MonkeyCode/backend/db/audit" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" "github.com/chaitin/MonkeyCode/backend/db/gitbot" "github.com/chaitin/MonkeyCode/backend/db/gitbotuser" "github.com/chaitin/MonkeyCode/backend/db/gitidentity" @@ -63,6 +64,7 @@ type UserQuery struct { withProjectIssueComments *ProjectIssueCommentQuery withGitBots *GitBotQuery withMcpUpstreams *MCPUpstreamQuery + withEndpoints *EndpointQuery withTeamMembers *TeamMemberQuery withTeamGroupMembers *TeamGroupMemberQuery withGitBotUsers *GitBotUserQuery @@ -499,6 +501,28 @@ func (_q *UserQuery) QueryMcpUpstreams() *MCPUpstreamQuery { return query } +// QueryEndpoints chains the current query on the "endpoints" edge. +func (_q *UserQuery) QueryEndpoints() *EndpointQuery { + query := (&EndpointClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(user.Table, user.FieldID, selector), + sqlgraph.To(endpoint.Table, endpoint.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, user.EndpointsTable, user.EndpointsColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + // QueryTeamMembers chains the current query on the "team_members" edge. func (_q *UserQuery) QueryTeamMembers() *TeamMemberQuery { query := (&TeamMemberClient{config: _q.config}).Query() @@ -775,6 +799,7 @@ func (_q *UserQuery) Clone() *UserQuery { withProjectIssueComments: _q.withProjectIssueComments.Clone(), withGitBots: _q.withGitBots.Clone(), withMcpUpstreams: _q.withMcpUpstreams.Clone(), + withEndpoints: _q.withEndpoints.Clone(), withTeamMembers: _q.withTeamMembers.Clone(), withTeamGroupMembers: _q.withTeamGroupMembers.Clone(), withGitBotUsers: _q.withGitBotUsers.Clone(), @@ -983,6 +1008,17 @@ func (_q *UserQuery) WithMcpUpstreams(opts ...func(*MCPUpstreamQuery)) *UserQuer return _q } +// WithEndpoints tells the query-builder to eager-load the nodes that are connected to +// the "endpoints" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *UserQuery) WithEndpoints(opts ...func(*EndpointQuery)) *UserQuery { + query := (&EndpointClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withEndpoints = query + return _q +} + // WithTeamMembers tells the query-builder to eager-load the nodes that are connected to // the "team_members" edge. The optional arguments are used to configure the query builder of the edge. func (_q *UserQuery) WithTeamMembers(opts ...func(*TeamMemberQuery)) *UserQuery { @@ -1094,7 +1130,7 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e var ( nodes = []*User{} _spec = _q.querySpec() - loadedTypes = [21]bool{ + loadedTypes = [22]bool{ _q.withIdentities != nil, _q.withAudits != nil, _q.withTeams != nil, @@ -1113,6 +1149,7 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e _q.withProjectIssueComments != nil, _q.withGitBots != nil, _q.withMcpUpstreams != nil, + _q.withEndpoints != nil, _q.withTeamMembers != nil, _q.withTeamGroupMembers != nil, _q.withGitBotUsers != nil, @@ -1269,6 +1306,13 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e return nil, err } } + if query := _q.withEndpoints; query != nil { + if err := _q.loadEndpoints(ctx, query, nodes, + func(n *User) { n.Edges.Endpoints = []*Endpoint{} }, + func(n *User, e *Endpoint) { n.Edges.Endpoints = append(n.Edges.Endpoints, e) }); err != nil { + return nil, err + } + } if query := _q.withTeamMembers; query != nil { if err := _q.loadTeamMembers(ctx, query, nodes, func(n *User) { n.Edges.TeamMembers = []*TeamMember{} }, @@ -1929,6 +1973,36 @@ func (_q *UserQuery) loadMcpUpstreams(ctx context.Context, query *MCPUpstreamQue } return nil } +func (_q *UserQuery) loadEndpoints(ctx context.Context, query *EndpointQuery, nodes []*User, init func(*User), assign func(*User, *Endpoint)) error { + fks := make([]driver.Value, 0, len(nodes)) + nodeids := make(map[uuid.UUID]*User) + for i := range nodes { + fks = append(fks, nodes[i].ID) + nodeids[nodes[i].ID] = nodes[i] + if init != nil { + init(nodes[i]) + } + } + if len(query.ctx.Fields) > 0 { + query.ctx.AppendFieldOnce(endpoint.FieldUserID) + } + query.Where(predicate.Endpoint(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(user.EndpointsColumn), fks...)) + })) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + fk := n.UserID + node, ok := nodeids[fk] + if !ok { + return fmt.Errorf(`unexpected referenced foreign-key "user_id" returned %v for node %v`, fk, n.ID) + } + assign(node, n) + } + return nil +} func (_q *UserQuery) loadTeamMembers(ctx context.Context, query *TeamMemberQuery, nodes []*User, init func(*User), assign func(*User, *TeamMember)) error { fks := make([]driver.Value, 0, len(nodes)) nodeids := make(map[uuid.UUID]*User) diff --git a/backend/db/user_update.go b/backend/db/user_update.go index 3cc5121ff..0fb13199e 100644 --- a/backend/db/user_update.go +++ b/backend/db/user_update.go @@ -13,6 +13,7 @@ import ( "entgo.io/ent/schema/field" "github.com/chaitin/MonkeyCode/backend/consts" "github.com/chaitin/MonkeyCode/backend/db/audit" + "github.com/chaitin/MonkeyCode/backend/db/endpoint" "github.com/chaitin/MonkeyCode/backend/db/gitbot" "github.com/chaitin/MonkeyCode/backend/db/gitbotuser" "github.com/chaitin/MonkeyCode/backend/db/gitidentity" @@ -489,6 +490,21 @@ func (_u *UserUpdate) AddMcpUpstreams(v ...*MCPUpstream) *UserUpdate { return _u.AddMcpUpstreamIDs(ids...) } +// AddEndpointIDs adds the "endpoints" edge to the Endpoint entity by IDs. +func (_u *UserUpdate) AddEndpointIDs(ids ...uuid.UUID) *UserUpdate { + _u.mutation.AddEndpointIDs(ids...) + return _u +} + +// AddEndpoints adds the "endpoints" edges to the Endpoint entity. +func (_u *UserUpdate) AddEndpoints(v ...*Endpoint) *UserUpdate { + ids := make([]uuid.UUID, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddEndpointIDs(ids...) +} + // AddTeamMemberIDs adds the "team_members" edge to the TeamMember entity by IDs. func (_u *UserUpdate) AddTeamMemberIDs(ids ...uuid.UUID) *UserUpdate { _u.mutation.AddTeamMemberIDs(ids...) @@ -917,6 +933,27 @@ func (_u *UserUpdate) RemoveMcpUpstreams(v ...*MCPUpstream) *UserUpdate { return _u.RemoveMcpUpstreamIDs(ids...) } +// ClearEndpoints clears all "endpoints" edges to the Endpoint entity. +func (_u *UserUpdate) ClearEndpoints() *UserUpdate { + _u.mutation.ClearEndpoints() + return _u +} + +// RemoveEndpointIDs removes the "endpoints" edge to Endpoint entities by IDs. +func (_u *UserUpdate) RemoveEndpointIDs(ids ...uuid.UUID) *UserUpdate { + _u.mutation.RemoveEndpointIDs(ids...) + return _u +} + +// RemoveEndpoints removes "endpoints" edges to Endpoint entities. +func (_u *UserUpdate) RemoveEndpoints(v ...*Endpoint) *UserUpdate { + ids := make([]uuid.UUID, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveEndpointIDs(ids...) +} + // ClearTeamMembers clears all "team_members" edges to the TeamMember entity. func (_u *UserUpdate) ClearTeamMembers() *UserUpdate { _u.mutation.ClearTeamMembers() @@ -1953,6 +1990,51 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.EndpointsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.EndpointsTable, + Columns: []string{user.EndpointsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedEndpointsIDs(); len(nodes) > 0 && !_u.mutation.EndpointsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.EndpointsTable, + Columns: []string{user.EndpointsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.EndpointsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.EndpointsTable, + Columns: []string{user.EndpointsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } if _u.mutation.TeamMembersCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -2548,6 +2630,21 @@ func (_u *UserUpdateOne) AddMcpUpstreams(v ...*MCPUpstream) *UserUpdateOne { return _u.AddMcpUpstreamIDs(ids...) } +// AddEndpointIDs adds the "endpoints" edge to the Endpoint entity by IDs. +func (_u *UserUpdateOne) AddEndpointIDs(ids ...uuid.UUID) *UserUpdateOne { + _u.mutation.AddEndpointIDs(ids...) + return _u +} + +// AddEndpoints adds the "endpoints" edges to the Endpoint entity. +func (_u *UserUpdateOne) AddEndpoints(v ...*Endpoint) *UserUpdateOne { + ids := make([]uuid.UUID, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddEndpointIDs(ids...) +} + // AddTeamMemberIDs adds the "team_members" edge to the TeamMember entity by IDs. func (_u *UserUpdateOne) AddTeamMemberIDs(ids ...uuid.UUID) *UserUpdateOne { _u.mutation.AddTeamMemberIDs(ids...) @@ -2976,6 +3073,27 @@ func (_u *UserUpdateOne) RemoveMcpUpstreams(v ...*MCPUpstream) *UserUpdateOne { return _u.RemoveMcpUpstreamIDs(ids...) } +// ClearEndpoints clears all "endpoints" edges to the Endpoint entity. +func (_u *UserUpdateOne) ClearEndpoints() *UserUpdateOne { + _u.mutation.ClearEndpoints() + return _u +} + +// RemoveEndpointIDs removes the "endpoints" edge to Endpoint entities by IDs. +func (_u *UserUpdateOne) RemoveEndpointIDs(ids ...uuid.UUID) *UserUpdateOne { + _u.mutation.RemoveEndpointIDs(ids...) + return _u +} + +// RemoveEndpoints removes "endpoints" edges to Endpoint entities. +func (_u *UserUpdateOne) RemoveEndpoints(v ...*Endpoint) *UserUpdateOne { + ids := make([]uuid.UUID, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveEndpointIDs(ids...) +} + // ClearTeamMembers clears all "team_members" edges to the TeamMember entity. func (_u *UserUpdateOne) ClearTeamMembers() *UserUpdateOne { _u.mutation.ClearTeamMembers() @@ -4042,6 +4160,51 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.EndpointsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.EndpointsTable, + Columns: []string{user.EndpointsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedEndpointsIDs(); len(nodes) > 0 && !_u.mutation.EndpointsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.EndpointsTable, + Columns: []string{user.EndpointsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.EndpointsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.EndpointsTable, + Columns: []string{user.EndpointsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(endpoint.FieldID, field.TypeUUID), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } if _u.mutation.TeamMembersCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/backend/domain/endpoint.go b/backend/domain/endpoint.go new file mode 100644 index 000000000..cc262bf04 --- /dev/null +++ b/backend/domain/endpoint.go @@ -0,0 +1,59 @@ +package domain + +import ( + "encoding/json" + + "github.com/google/uuid" +) + +const ( + EndpointStatusActive = "active" + EndpointStatusRevoked = "revoked" +) + +type EndpointProfile struct { + DeviceName string `json:"device_name"` + Platform string `json:"platform"` + OSVersion string `json:"os_version"` + Arch string `json:"arch"` + ClientVersion string `json:"client_version"` +} + +type EndpointView struct { + MachineID uuid.UUID `json:"machine_id"` + DeviceName string `json:"device_name"` + Alias *string `json:"alias"` + DisplayName string `json:"display_name"` + Platform string `json:"platform"` + OSVersion string `json:"os_version"` + Arch string `json:"arch"` + ClientVersion string `json:"client_version"` + ProtocolVersion int `json:"protocol_version"` + Status string `json:"status,omitempty"` + Online bool `json:"online"` + LastSeenAt *int64 `json:"last_seen_at"` + CreatedAt *int64 `json:"created_at,omitempty"` + UpdatedAt *int64 `json:"updated_at,omitempty"` +} + +type EndpointHello struct { + Type string `json:"type"` + ProtocolVersions []int `json:"protocol_versions"` + MachineID uuid.UUID `json:"machine_id"` + Profile EndpointProfile `json:"profile"` +} + +type EndpointEnvelope struct { + Type string `json:"type"` + MessageID uuid.UUID `json:"message_id"` + Target uuid.UUID `json:"target"` + Method string `json:"method,omitempty"` + ReplyTo *uuid.UUID `json:"reply_to,omitempty"` + Payload json.RawMessage `json:"payload"` + Source *uuid.UUID `json:"source,omitempty"` + RoutedAt *int64 `json:"routed_at,omitempty"` +} + +type EndpointPathReq struct { + MachineID uuid.UUID `param:"machine_id" validate:"required"` +} diff --git a/backend/ent/schema/endpoint.go b/backend/ent/schema/endpoint.go new file mode 100644 index 000000000..ae6d381ee --- /dev/null +++ b/backend/ent/schema/endpoint.go @@ -0,0 +1,52 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" + "github.com/google/uuid" +) + +type Endpoint struct { + ent.Schema +} + +func (Endpoint) Annotations() []schema.Annotation { + return []schema.Annotation{entsql.Table("endpoints")} +} + +func (Endpoint) Fields() []ent.Field { + return []ent.Field{ + field.UUID("id", uuid.UUID{}).Default(uuid.New), + field.UUID("user_id", uuid.UUID{}), + field.UUID("machine_id", uuid.UUID{}), + field.String("device_name").NotEmpty(), + field.Enum("platform").Values("macos", "windows", "linux", "ios", "android"), + field.String("os_version"), + field.String("arch"), + field.String("client_version"), + field.Int("protocol_version").Default(1), + field.String("alias").Optional().Nillable(), + field.Enum("status").Values("active", "revoked").Default("active"), + field.Time("last_seen_at").Optional().Nillable(), + field.Time("created_at").Default(time.Now), + field.Time("updated_at").Default(time.Now).UpdateDefault(time.Now), + } +} + +func (Endpoint) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("user", User.Type).Field("user_id").Ref("endpoints").Unique().Required(), + } +} + +func (Endpoint) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("user_id", "machine_id").Unique(), + } +} diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index be0797783..5c0efcd6d 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -69,5 +69,6 @@ func (User) Edges() []ent.Edge { edge.To("project_issue_comments", ProjectIssueComment.Type), edge.To("git_bots", GitBot.Type).Through("git_bot_users", GitBotUser.Type), edge.To("mcp_upstreams", MCPUpstream.Type), + edge.To("endpoints", Endpoint.Type), } } diff --git a/backend/errcode/errcode.go b/backend/errcode/errcode.go index 18fb79617..507a939bb 100644 --- a/backend/errcode/errcode.go +++ b/backend/errcode/errcode.go @@ -23,15 +23,16 @@ var ( // 业务层面的错误 var ( // 通用的错误 - ErrInternalServer = web.NewErr(http.StatusOK, 10000, "err-internal-server") - ErrPermision = web.NewErr(http.StatusOK, 10001, "err-permision-denied") - ErrNotFound = web.NewErr(http.StatusOK, 10002, "err-not-found") - ErrDuplicate = web.NewErr(http.StatusOK, 10003, "err-duplicate") - ErrDatabaseQuery = web.NewErr(http.StatusOK, 10004, "err-database-query") - ErrDatabaseOperation = web.NewErr(http.StatusOK, 10005, "err-database-operation-failed") - ErrHTTPRequest = web.NewErr(http.StatusOK, 10006, "err-http-request-failed") - ErrHasInvalidEntry = web.NewErr(http.StatusOK, 10007, "err-has-invalid-entry") - ErrStreamDisconnect = web.NewErr(http.StatusOK, 10008, "err-stream-disconnect") + ErrInternalServer = web.NewErr(http.StatusOK, 10000, "err-internal-server") + ErrPermision = web.NewErr(http.StatusOK, 10001, "err-permision-denied") + ErrNotFound = web.NewErr(http.StatusOK, 10002, "err-not-found") + ErrDuplicate = web.NewErr(http.StatusOK, 10003, "err-duplicate") + ErrDatabaseQuery = web.NewErr(http.StatusOK, 10004, "err-database-query") + ErrDatabaseOperation = web.NewErr(http.StatusOK, 10005, "err-database-operation-failed") + ErrHTTPRequest = web.NewErr(http.StatusOK, 10006, "err-http-request-failed") + ErrHasInvalidEntry = web.NewErr(http.StatusOK, 10007, "err-has-invalid-entry") + ErrStreamDisconnect = web.NewErr(http.StatusOK, 10008, "err-stream-disconnect") + ErrEndpointLimitExceeded = web.NewErr(http.StatusOK, 10009, "err-endpoint-limit-exceeded") // 业务模块特有的 错误码 diff --git a/backend/errcode/locale.en.toml b/backend/errcode/locale.en.toml index 206c93e00..040f0ae72 100644 --- a/backend/errcode/locale.en.toml +++ b/backend/errcode/locale.en.toml @@ -302,3 +302,5 @@ other = "License is not active yet" [err-license-machine-mismatch] other = "License machine code does not match" +[err-endpoint-limit-exceeded] +other = "Endpoint limit exceeded" diff --git a/backend/errcode/locale.zh.toml b/backend/errcode/locale.zh.toml index 90ceb208c..3e0b87e83 100644 --- a/backend/errcode/locale.zh.toml +++ b/backend/errcode/locale.zh.toml @@ -300,3 +300,5 @@ other = "License 尚未生效" [err-license-machine-mismatch] other = "License 机器码不匹配" +[err-endpoint-limit-exceeded] +other = "端点数量已达上限" diff --git a/backend/pkg/session/session.go b/backend/pkg/session/session.go index 11bc2aacc..11360a039 100644 --- a/backend/pkg/session/session.go +++ b/backend/pkg/session/session.go @@ -3,6 +3,7 @@ package session import ( "context" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -104,6 +105,26 @@ func Get[T any](s *Session, c echo.Context, name string) (T, error) { return t, nil } +func (s *Session) Valid(ctx context.Context, name, cookie string) bool { + valid, _ := s.Check(ctx, name, cookie) + return valid +} + +func (s *Session) Check(ctx context.Context, name, cookie string) (bool, error) { + rawUID, err := s.rdb.Get(ctx, lookupKey(name, cookie)).Result() + if errors.Is(err, redis.Nil) { + return false, nil + } + if err != nil { + return false, err + } + uid, err := uuid.Parse(rawUID) + if err != nil { + return false, nil + } + return s.rdb.HExists(ctx, hashKey(name, uid), cookie).Result() +} + // Del 删除单个 session(登出) func (s *Session) Del(c echo.Context, name string, uid uuid.UUID) error { ctx := c.Request().Context() diff --git a/desktop/electron/main.cjs b/desktop/electron/main.cjs index ec6e9dfed..1ad310416 100644 --- a/desktop/electron/main.cjs +++ b/desktop/electron/main.cjs @@ -1,12 +1,17 @@ -const { app, BrowserWindow, shell, dialog, Menu } = require("electron") +const { app, BrowserWindow, shell, dialog, Menu, ipcMain, safeStorage } = require("electron") +const { randomUUID } = require("crypto") const fs = require("fs") +const os = require("os") const path = require("path") +const WebSocket = require("ws") const isDev = !app.isPackaged const DEFAULT_PROD_URL = "https://monkeycode-ai.com" const ERR_ABORTED = -3 /** 桌面端启动路径(相对站点根),可用 MONKEYCODE_DESKTOP_START_PATH 覆盖 */ const START_PATH = (process.env.MONKEYCODE_DESKTOP_START_PATH || "/console/").replace(/\/$/, "") || "/console" +const ENDPOINT_MAX_FRAME_BYTES = 256 * 1024 +const endpointConnections = new Map() function desktopEntryUrl(base) { const href = (base || "").trim() || DEFAULT_PROD_URL @@ -47,6 +52,145 @@ function isBenignLoadFailure(code, desc) { return code === ERR_ABORTED || desc === "ERR_ABORTED" } +function endpointIdentityPath() { + return path.join(app.getPath("userData"), "endpoint-machine-id") +} + +function endpointMachineId() { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error("操作系统安全存储不可用") + } + const filename = endpointIdentityPath() + try { + const encrypted = fs.readFileSync(filename) + const machineId = safeStorage.decryptString(encrypted) + if (/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(machineId)) { + return machineId + } + } catch { + // 首次安装或本地安全存储已失效时生成新标识。 + } + const machineId = randomUUID() + fs.mkdirSync(path.dirname(filename), { recursive: true }) + fs.writeFileSync(filename, safeStorage.encryptString(machineId), { mode: 0o600 }) + return machineId +} + +function endpointBaseUrl(sender, requested) { + const senderUrl = new URL(sender.getURL()) + if (senderUrl.protocol === "http:" || senderUrl.protocol === "https:") { + const base = new URL(requested || senderUrl.origin) + if (base.origin !== senderUrl.origin) throw new Error("桥接地址必须与客户端页面同源") + return base + } + const configured = new URL(process.env.MONKEYCODE_DESKTOP_URL || DEFAULT_PROD_URL) + if (requested && new URL(requested).origin !== configured.origin) { + throw new Error("桥接地址不受信任") + } + return configured +} + +async function startEndpointBridge(event, requestedBaseUrl) { + const sender = event.sender + stopEndpointBridge(sender.id) + const base = endpointBaseUrl(sender, requestedBaseUrl) + const entry = { + sender, + base, + socket: null, + timer: null, + attempt: 0, + stableSince: 0, + stopped: false, + } + endpointConnections.set(sender.id, entry) + try { + await connectEndpointBridge(entry) + } catch (error) { + stopEndpointBridge(sender.id) + throw error + } + return { machine_id: endpointMachineId() } +} + +async function connectEndpointBridge(entry) { + if (entry.stopped || entry.sender.isDestroyed()) return + const { sender, base } = entry + const cookies = await sender.session.cookies.get({ url: base.origin }) + const cookieHeader = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ") + const wsUrl = new URL("/api/v1/endpoints/connect", base) + wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket(wsUrl, { + headers: cookieHeader ? { Cookie: cookieHeader } : undefined, + maxPayload: ENDPOINT_MAX_FRAME_BYTES, + perMessageDeflate: false, + }) + entry.socket = socket + socket.on("open", () => { + if (entry.stopped || entry.socket !== socket) return + entry.stableSince = Date.now() + const platform = process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : process.platform + socket.send(JSON.stringify({ + type: "hello", + protocol_versions: [1], + machine_id: endpointMachineId(), + profile: { + device_name: os.hostname(), + platform, + os_version: os.release(), + arch: process.arch, + client_version: app.getVersion(), + }, + })) + }) + socket.on("message", (data, isBinary) => { + if (isBinary || data.byteLength > ENDPOINT_MAX_FRAME_BYTES || sender.isDestroyed()) return + sender.send("endpoint-bridge-message", data.toString("utf8")) + }) + socket.on("close", (code, reason) => { + if (entry.socket === socket) entry.socket = null + if (!sender.isDestroyed()) { + sender.send("endpoint-bridge-close", { code, reason: reason.toString("utf8") }) + } + if (entry.stopped || ![1006, 1011, 1012, 1013].includes(code)) return + if (entry.stableSince > 0 && Date.now() - entry.stableSince >= 60_000) entry.attempt = 0 + const delays = [500, 1000, 2000, 4000, 8000, 16_000, 30_000] + const baseDelay = delays[Math.min(entry.attempt, delays.length - 1)] + entry.attempt += 1 + entry.timer = setTimeout(() => { + entry.timer = null + void connectEndpointBridge(entry).catch((error) => { + if (!sender.isDestroyed()) sender.send("endpoint-bridge-error", error.message) + }) + }, Math.round(baseDelay * (0.8 + Math.random() * 0.4))) + }) + socket.on("error", (error) => { + if (!sender.isDestroyed()) sender.send("endpoint-bridge-error", error.message) + }) +} + +function stopEndpointBridge(senderId) { + const entry = endpointConnections.get(senderId) + if (!entry) return + endpointConnections.delete(senderId) + entry.stopped = true + if (entry.timer) clearTimeout(entry.timer) + entry.socket?.close(1000, "client stopped") +} + +function sendEndpointMessage(event, message) { + const entry = endpointConnections.get(event.sender.id) + if (!entry?.socket || entry.socket.readyState !== WebSocket.OPEN) throw new Error("端点桥接连接尚未就绪") + if (typeof message !== "string" || Buffer.byteLength(message, "utf8") > ENDPOINT_MAX_FRAME_BYTES) { + throw new Error("端点消息无效或超过大小限制") + } + const envelope = JSON.parse(message) + if (!["event", "request", "response"].includes(envelope?.type)) { + throw new Error("端点消息类型无效") + } + entry.socket.send(message) +} + function createWindow() { const win = new BrowserWindow({ width: 1280, @@ -124,6 +268,9 @@ if (!gotLock) { const icon = windowIconPath() if (icon) app.dock.setIcon(icon) } + ipcMain.handle("endpoint-bridge-start", startEndpointBridge) + ipcMain.handle("endpoint-bridge-stop", (event) => stopEndpointBridge(event.sender.id)) + ipcMain.handle("endpoint-bridge-send", sendEndpointMessage) createWindow() }) } @@ -132,6 +279,10 @@ app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit() }) +app.on("web-contents-created", (_event, contents) => { + contents.once("destroyed", () => stopEndpointBridge(contents.id)) +}) + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) diff --git a/desktop/electron/preload.cjs b/desktop/electron/preload.cjs index cec9af7e9..aa184d6a8 100644 --- a/desktop/electron/preload.cjs +++ b/desktop/electron/preload.cjs @@ -1,2 +1,20 @@ "use strict" -// Preload:若需向页面暴露安全 API,请使用 contextBridge.exposeInMainWorld。 +const { contextBridge, ipcRenderer } = require("electron") + +contextBridge.exposeInMainWorld("monkeyCodeDesktop", { + endpointBridge: { + start: (baseUrl) => ipcRenderer.invoke("endpoint-bridge-start", baseUrl), + stop: () => ipcRenderer.invoke("endpoint-bridge-stop"), + send: (message) => ipcRenderer.invoke("endpoint-bridge-send", message), + onMessage: (listener) => { + const callback = (_event, message) => listener(message) + ipcRenderer.on("endpoint-bridge-message", callback) + return () => ipcRenderer.removeListener("endpoint-bridge-message", callback) + }, + onClose: (listener) => { + const callback = (_event, detail) => listener(detail) + ipcRenderer.on("endpoint-bridge-close", callback) + return () => ipcRenderer.removeListener("endpoint-bridge-close", callback) + }, + }, +}) diff --git a/desktop/package.json b/desktop/package.json index 3bd7e17a9..093b1b6dd 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -63,5 +63,8 @@ ] }, "asar": true + }, + "dependencies": { + "ws": "^8.21.1" } } diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 9d0b80404..567141adf 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + ws: + specifier: ^8.21.1 + version: 8.21.1 devDependencies: concurrently: specifier: ^9.2.1 @@ -1302,6 +1306,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -2826,6 +2842,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.1: {} + xmlbuilder@15.1.1: {} y18n@5.0.8: {} diff --git "a/docs/adr/0001-\347\253\257\347\202\271\345\217\221\347\216\260\351\231\220\345\256\232\344\272\216\345\220\214\344\270\200\347\224\250\346\210\267.md" "b/docs/adr/0001-\347\253\257\347\202\271\345\217\221\347\216\260\351\231\220\345\256\232\344\272\216\345\220\214\344\270\200\347\224\250\346\210\267.md" new file mode 100644 index 000000000..eeebd5004 --- /dev/null +++ "b/docs/adr/0001-\347\253\257\347\202\271\345\217\221\347\216\260\351\231\220\345\256\232\344\272\216\345\220\214\344\270\200\347\224\250\346\210\267.md" @@ -0,0 +1,3 @@ +# 端点发现限定于同一用户 + +端点只允许发现和通信到同一 MonkeyCode 用户拥有的其他端点。团队成员、项目协作者和管理员权限不自动获得端点访问权,因为任务管理权限不应隐式扩大为对用户本地运行环境的控制权;跨用户通信必须另行获得显式、短期且可撤销的授权。 diff --git "a/docs/adr/0002-\344\275\277\347\224\250\345\256\211\350\243\205\347\272\247\351\232\217\346\234\272\346\240\207\350\257\206\350\257\206\345\210\253\347\253\257\347\202\271.md" "b/docs/adr/0002-\344\275\277\347\224\250\345\256\211\350\243\205\347\272\247\351\232\217\346\234\272\346\240\207\350\257\206\350\257\206\345\210\253\347\253\257\347\202\271.md" new file mode 100644 index 000000000..7b45e5c7a --- /dev/null +++ "b/docs/adr/0002-\344\275\277\347\224\250\345\256\211\350\243\205\347\272\247\351\232\217\346\234\272\346\240\207\350\257\206\350\257\206\345\210\253\347\253\257\347\202\271.md" @@ -0,0 +1,3 @@ +# 使用安装级随机标识识别端点 + +端点由 MonkeyCode 首次安装时生成的随机 UUID 标识,服务端以用户身份与机器标识的组合识别端点;应用重启不改变身份,卸载重装则产生新端点。该方案不使用硬件指纹,因为各操作系统缺少统一、稳定且合规的硬件标识,硬件标识还会引入隐私、权限和设备克隆风险。 diff --git "a/docs/adr/0003-\351\246\226\347\211\210\344\270\215\346\217\220\344\276\233\347\253\257\345\210\260\347\253\257\345\212\240\345\257\206.md" "b/docs/adr/0003-\351\246\226\347\211\210\344\270\215\346\217\220\344\276\233\347\253\257\345\210\260\347\253\257\345\212\240\345\257\206.md" new file mode 100644 index 000000000..bbdfb79f0 --- /dev/null +++ "b/docs/adr/0003-\351\246\226\347\211\210\344\270\215\346\217\220\344\276\233\347\253\257\345\210\260\347\253\257\345\212\240\345\257\206.md" @@ -0,0 +1,3 @@ +# 首版不提供端到端加密 + +首版协议使用 WSS 保护传输,桥接服务终止 TLS、校验协议信封并路由消息,因此技术上能够接触载荷字节,但不解释载荷的业务语义。暂不引入端到端加密,避免在基础通信协议中同时承担设备密钥、配对确认、密钥轮换、丢失恢复和多端同步等复杂度。 diff --git "a/docs/adr/0004-\346\241\245\346\216\245\346\234\215\345\212\241\344\273\205\346\217\220\344\276\233\345\234\250\347\272\277\350\267\257\347\224\261.md" "b/docs/adr/0004-\346\241\245\346\216\245\346\234\215\345\212\241\344\273\205\346\217\220\344\276\233\345\234\250\347\272\277\350\267\257\347\224\261.md" new file mode 100644 index 000000000..5d23bfaae --- /dev/null +++ "b/docs/adr/0004-\346\241\245\346\216\245\346\234\215\345\212\241\344\273\205\346\217\220\344\276\233\345\234\250\347\272\277\350\267\257\347\224\261.md" @@ -0,0 +1,3 @@ +# 桥接服务仅提供在线路由 + +桥接服务只向当前在线的目标端点转发消息,不保存载荷供离线端点上线后补发;目标离线时发送方立即得到不可投递结果。延迟执行已经过期的控制指令具有安全风险,任务状态恢复应由客户端重连后重新查询权威状态并重新发起操作。 diff --git "a/docs/adr/0005-\346\257\217\344\270\252\347\253\257\347\202\271\345\217\252\344\277\235\347\225\231\344\270\200\346\235\241\346\234\211\346\225\210\350\277\236\346\216\245.md" "b/docs/adr/0005-\346\257\217\344\270\252\347\253\257\347\202\271\345\217\252\344\277\235\347\225\231\344\270\200\346\235\241\346\234\211\346\225\210\350\277\236\346\216\245.md" new file mode 100644 index 000000000..e63f10dc8 --- /dev/null +++ "b/docs/adr/0005-\346\257\217\344\270\252\347\253\257\347\202\271\345\217\252\344\277\235\347\225\231\344\270\200\346\235\241\346\234\211\346\225\210\350\277\236\346\216\245.md" @@ -0,0 +1,3 @@ +# 每个端点只保留一条有效连接 + +同一用户与机器标识对应的端点同时只能有一条有效连接;新连接鉴权成功后原子替换旧连接,旧连接收到替换原因后关闭。该规则避免网络重连重叠或机器标识被复制时,同一控制指令被多个进程重复执行。 diff --git "a/docs/adr/0006-\347\253\257\347\202\271\351\211\264\346\235\203\345\244\215\347\224\250\347\224\250\346\210\267\344\274\232\350\257\235.md" "b/docs/adr/0006-\347\253\257\347\202\271\351\211\264\346\235\203\345\244\215\347\224\250\347\224\250\346\210\267\344\274\232\350\257\235.md" new file mode 100644 index 000000000..24cd55ae8 --- /dev/null +++ "b/docs/adr/0006-\347\253\257\347\202\271\351\211\264\346\235\203\345\244\215\347\224\250\347\224\250\346\210\267\344\274\232\350\257\235.md" @@ -0,0 +1,3 @@ +# 端点鉴权复用用户会话 + +桥接连接复用现有 MonkeyCode 登录 Cookie 鉴别用户身份,机器标识只用于识别和寻址端点,不被视为鉴权凭证,首版也不签发额外的端点凭证。撤销端点时保留用户与机器标识的撤销记录、立即断开连接并阻止自动重新登记;持有有效用户会话的客户端可以显式调用接口恢复,因此撤销是管理状态而非失窃处置边界,真正处置失窃设备还需使其登录会话失效。 diff --git "a/docs/adr/0007-\347\253\257\347\202\271\344\270\216Agent\344\270\200\344\270\200\345\257\271\345\272\224.md" "b/docs/adr/0007-\347\253\257\347\202\271\344\270\216Agent\344\270\200\344\270\200\345\257\271\345\272\224.md" new file mode 100644 index 000000000..e1f9f32a4 --- /dev/null +++ "b/docs/adr/0007-\347\253\257\347\202\271\344\270\216Agent\344\270\200\344\270\200\345\257\271\345\272\224.md" @@ -0,0 +1,3 @@ +# 端点与 Agent 一一对应 + +每个端点只代表一个 Agent,Agent 与端点共享身份,桥接服务直接按端点寻址,不提供同一端点下的多 Agent 注册或二级路由。任务和具体能力由 Agent 自身管理,避免桥接协议耦合 Agent 的内部结构。 diff --git "a/docs/adr/0008-\350\257\267\346\261\202\347\212\266\346\200\201\347\224\261\345\256\242\346\210\267\347\253\257\347\273\264\346\212\244.md" "b/docs/adr/0008-\350\257\267\346\261\202\347\212\266\346\200\201\347\224\261\345\256\242\346\210\267\347\253\257\347\273\264\346\212\244.md" new file mode 100644 index 000000000..cf8a8273c --- /dev/null +++ "b/docs/adr/0008-\350\257\267\346\261\202\347\212\266\346\200\201\347\224\261\345\256\242\346\210\267\347\253\257\347\273\264\346\212\244.md" @@ -0,0 +1,3 @@ +# 请求状态由客户端维护 + +业务消息由发送方生成唯一消息标识,响应通过引用原请求标识完成关联;pending 请求、超时和取消均由客户端维护。桥接服务不维护业务请求状态,也不提供送达确认,只负责无状态鉴权与路由,并对路由前即可确定的失败立即返回协议错误。 diff --git "a/docs/adr/0009-\346\240\270\345\277\203\345\215\217\350\256\256\344\270\215\350\207\252\345\212\250\351\207\215\350\257\225\344\270\232\345\212\241\346\266\210\346\201\257.md" "b/docs/adr/0009-\346\240\270\345\277\203\345\215\217\350\256\256\344\270\215\350\207\252\345\212\250\351\207\215\350\257\225\344\270\232\345\212\241\346\266\210\346\201\257.md" new file mode 100644 index 000000000..046e8877b --- /dev/null +++ "b/docs/adr/0009-\346\240\270\345\277\203\345\215\217\350\256\256\344\270\215\350\207\252\345\212\250\351\207\215\350\257\225\344\270\232\345\212\241\346\266\210\346\201\257.md" @@ -0,0 +1,3 @@ +# 核心协议不自动重试业务消息 + +桥接服务和通用客户端库都不自动重试业务消息;请求超时只表示结果未知,不能推断目标未执行。只有具体 Agent 协议确认操作幂等后才能自行重试,并复用原消息标识供接收端去重,避免非幂等控制指令被重复执行。 diff --git "a/docs/adr/0010-\346\240\270\345\277\203\345\215\217\350\256\256\344\275\277\347\224\250JSON\346\226\207\346\234\254\345\270\247.md" "b/docs/adr/0010-\346\240\270\345\277\203\345\215\217\350\256\256\344\275\277\347\224\250JSON\346\226\207\346\234\254\345\270\247.md" new file mode 100644 index 000000000..a93e8d078 --- /dev/null +++ "b/docs/adr/0010-\346\240\270\345\277\203\345\215\217\350\256\256\344\275\277\347\224\250JSON\346\226\207\346\234\254\345\270\247.md" @@ -0,0 +1,3 @@ +# 核心协议使用 JSON 文本帧 + +首版协议使用 WebSocket UTF-8 文本帧,每帧承载一个 JSON 信封,普通载荷直接使用 JSON 值,不再 Base64 包装整个 JSON,也不支持二进制帧。本地文件的消息方法、分块和编码方式由 Agent 自定义,但产生的每个信封仍需遵守 256 KiB 单帧上限。 diff --git "a/docs/adr/0011-\346\241\245\346\216\245\350\277\236\346\216\245\346\240\241\351\252\214WebSocket\346\235\245\346\272\220.md" "b/docs/adr/0011-\346\241\245\346\216\245\350\277\236\346\216\245\346\240\241\351\252\214WebSocket\346\235\245\346\272\220.md" new file mode 100644 index 000000000..de6ab4622 --- /dev/null +++ "b/docs/adr/0011-\346\241\245\346\216\245\350\277\236\346\216\245\346\240\241\351\252\214WebSocket\346\235\245\346\272\220.md" @@ -0,0 +1,3 @@ +# 桥接连接校验 WebSocket 来源 + +桥接连接复用登录 Cookie,因此 WebSocket 升级请求携带非空 `Origin` 时必须匹配配置的可信域名白名单,原生客户端未携带 `Origin` 时仍需通过用户会话鉴权。新桥接端点不得使用现有 WebSocket 工具中的宽松跨域升级设置,避免恶意网页借用用户 Cookie 建立跨站连接。 diff --git "a/docs/adr/0012-\346\241\245\346\216\245\346\234\215\345\212\241\346\224\257\346\214\201\345\244\232\345\211\257\346\234\254\350\267\257\347\224\261.md" "b/docs/adr/0012-\346\241\245\346\216\245\346\234\215\345\212\241\346\224\257\346\214\201\345\244\232\345\211\257\346\234\254\350\267\257\347\224\261.md" new file mode 100644 index 000000000..1b6869fc2 --- /dev/null +++ "b/docs/adr/0012-\346\241\245\346\216\245\346\234\215\345\212\241\346\224\257\346\214\201\345\244\232\345\211\257\346\234\254\350\267\257\347\224\261.md" @@ -0,0 +1,3 @@ +# 桥接服务支持多副本路由 + +桥接服务从首版支持后端多副本:PostgreSQL 保存持久化端点目录,各实例只持有本地 WebSocket 连接,Redis 以 60 秒租约保存用户、机器标识对应的实例及连接代次,并在成功收到 Pong 后续租;跨实例消息通过 Redis Pub/Sub 转发。正常断开立即离线,实例异常退出则在租约过期后离线,路由发现目标实例不可达时立即失败。Redis 不可用时拒绝新连接或返回路由失败,不降级为不一致的本地在线状态;在线路由不承诺补发,因此 Pub/Sub 的瞬时语义与协议一致。 diff --git "a/docs/adr/0013-\346\241\245\346\216\245\346\234\215\345\212\241\344\270\215\350\256\260\345\275\225\344\270\232\345\212\241\350\275\275\350\215\267.md" "b/docs/adr/0013-\346\241\245\346\216\245\346\234\215\345\212\241\344\270\215\350\256\260\345\275\225\344\270\232\345\212\241\350\275\275\350\215\267.md" new file mode 100644 index 000000000..2d62ff6cb --- /dev/null +++ "b/docs/adr/0013-\346\241\245\346\216\245\346\234\215\345\212\241\344\270\215\350\256\260\345\275\225\344\270\232\345\212\241\350\275\275\350\215\267.md" @@ -0,0 +1,3 @@ +# 桥接服务不记录业务载荷 + +桥接服务的日志、指标和安全审计只记录用户、源和目标机器标识、消息标识、消息类型、方法、载荷大小、路由结果与延迟,任何环境和日志级别都不得记录业务载荷原文。桥接服务虽能在内存中接触载荷,但任务消息可能包含源码、终端输出或凭据,调试便利不能扩大其持久化暴露面。 diff --git "a/docs/adr/0014-\346\226\207\344\273\266\344\277\235\347\225\231\345\234\250\346\272\220\347\253\257\347\202\271\346\234\254\345\234\260.md" "b/docs/adr/0014-\346\226\207\344\273\266\344\277\235\347\225\231\345\234\250\346\272\220\347\253\257\347\202\271\346\234\254\345\234\260.md" new file mode 100644 index 000000000..79cd5b9b2 --- /dev/null +++ "b/docs/adr/0014-\346\226\207\344\273\266\344\277\235\347\225\231\345\234\250\346\272\220\347\253\257\347\202\271\346\234\254\345\234\260.md" @@ -0,0 +1,3 @@ +# 文件保留在源端点本地 + +Agent 产生或持有的文件不上传 OSS 或桥接服务,其他端点需要查看时通过 Agent 自定义协议传输,单个文件暂时限制为 5 MiB。桥接服务只在线转发消息且不持久化文件,传输方法、分块、编码、访问控制和变更检测均由 Agent 定义。 diff --git a/docs/superpowers/specs/2026-03-27-git-platform-interface-abstraction-design.md b/docs/design/2026-03-27-git-platform-interface-abstraction-design.md similarity index 100% rename from docs/superpowers/specs/2026-03-27-git-platform-interface-abstraction-design.md rename to docs/design/2026-03-27-git-platform-interface-abstraction-design.md diff --git a/docs/superpowers/specs/2026-07-14-skill-search-design.md b/docs/design/2026-07-14-skill-search-design.md similarity index 100% rename from docs/superpowers/specs/2026-07-14-skill-search-design.md rename to docs/design/2026-07-14-skill-search-design.md diff --git a/docs/superpowers/specs/2026-07-15-task-skill-wheel-scroll-design.md b/docs/design/2026-07-15-task-skill-wheel-scroll-design.md similarity index 100% rename from docs/superpowers/specs/2026-07-15-task-skill-wheel-scroll-design.md rename to docs/design/2026-07-15-task-skill-wheel-scroll-design.md diff --git a/docs/design/2026-07-17-client-bridge-client-protocol.md b/docs/design/2026-07-17-client-bridge-client-protocol.md new file mode 100644 index 000000000..06bb8f94b --- /dev/null +++ b/docs/design/2026-07-17-client-bridge-client-protocol.md @@ -0,0 +1,665 @@ +# MonkeyCode 端点桥接协议(客户端规范) + +## 1. 适用范围 + +本文只描述原生桌面端和移动端如何接入 MonkeyCode 端点桥接服务,不包含服务端存储、集群和跨实例路由实现。 + +协议主版本为 `1`,WebSocket 地址为: + +```text +/api/v1/endpoints/connect +``` + +协议提供: + +- 同一用户端点发现。 +- 端点间 JSON 消息单播。 +- Agent 事件、请求和响应。 + +协议不提供: + +- 纯浏览器端点。 +- 跨用户通信。 +- 离线消息。 +- 自动重试或消息去重。 +- 二进制消息或超过 5 MiB 的文件传输。 +- 任务、能力等 Agent 业务模型。 + +一个端点对应一个 Agent。所有端点在核心协议中地位对等,不区分控制端和被控端。 + +## 2. 客户端常量 + +| 配置 | 值 | +|---|---| +| 协议主版本 | `1` | +| 生产传输 | `wss://` | +| hello 超时 | 5 秒 | +| 单帧上限 | 256 KiB | +| 单文件上限 | 5 MiB | +| 默认请求超时 | 30 秒 | +| 服务端 Ping 间隔 | 30 秒 | +| Pong 超时 | 10 秒 | +| 默认端点上限 | 20 个未撤销端点 | + +生产环境必须使用 WSS。开发模式仅允许 `localhost`、`127.0.0.1` 和 `[::1]` 使用 WS。 + +## 3. 机器标识 + +### 3.1 生成 + +首次安装时生成一个 UUID,作为 `machine_id`: + +```text +4f1207be-1ce0-4e88-8e3e-e92690567ec8 +``` + +要求: + +- 使用随机 UUID。 +- 应用重启后保持不变。 +- 用户退出和切换账号时保持不变。 +- 卸载重装后可以生成新 UUID。 +- 不使用 MAC、硬盘序列号或硬件指纹。 + +### 3.2 存储 + +移动端保存到 Keychain、Keystore 等系统安全存储。桌面端保存到操作系统凭据或安全存储,不应由页面脚本自行生成临时值。 + +`machine_id` 只是端点标识,不是密码或鉴权凭证。 + +## 4. 鉴权 + +WebSocket 和端点管理 HTTP 接口都复用当前 MonkeyCode 登录 Cookie。 + +客户端要求: + +- WebSocket Upgrade 时携带现有登录 Cookie。 +- 不把 Cookie 或其他登录凭据放在 URL 查询参数中。 +- Cookie 失效后停止桥接连接,并进入重新登录流程。 +- 不允许客户端自行声明 `user_id`。 + +同一用户的新端点完成登记后立即可以与其他端点通信,不需要配对确认。 + +## 5. HTTP 接口 + +| 方法 | 路径 | 用途 | +|---|---|---| +| `GET` | `/api/v1/endpoints` | 查询全部端点,包括已撤销端点 | +| `GET` | `/api/v1/endpoints/{machine_id}` | 查询一个端点 | +| `PATCH` | `/api/v1/endpoints/{machine_id}` | 修改端点别名 | +| `POST` | `/api/v1/endpoints/{machine_id}/revoke` | 撤销端点 | +| `POST` | `/api/v1/endpoints/{machine_id}/restore` | 恢复端点 | +| `GET` | `/api/v1/endpoints/connect` | WebSocket Upgrade | + +撤销当前端点后,其 WebSocket 会被关闭,客户端不得自动重连。持有有效 Cookie 的客户端可以显式调用恢复接口,然后由用户触发重新连接。 + +端点记录不会自动删除。达到端点上限后,客户端应引导用户通过管理接口撤销不再使用的端点。 + +## 6. 连接状态机 + +建议客户端使用以下状态: + +```text +idle + ↓ connect() +connecting + ↓ WebSocket open +handshaking + ↓ welcome +ready + ↓ 可恢复断线 +backoff + ↓ 重连 +connecting + +任意状态 + ↓ 不可恢复关闭 +stopped +``` + +状态要求: + +- `connecting`:等待 WebSocket Upgrade。 +- `handshaking`:只能发送 `hello`。 +- `ready`:可以处理目录快照和 Agent 消息。 +- `backoff`:等待自动重连。 +- `stopped`:必须等待登录、恢复端点或用户操作。 + +客户端应为每次本地连接尝试分配递增序号,忽略旧 WebSocket 的延迟回调,避免旧连接触发重复重连。 + +## 7. 握手 + +### 7.1 流程 + +```text +客户端 服务端 + │ │ + │ WebSocket Upgrade+Cookie │ + ├──────────────────────────>│ + │ 101 Switching Protocols │ + │<──────────────────────────┤ + │ hello │ + ├──────────────────────────>│ + │ welcome │ + │<──────────────────────────┤ + │ directory.snapshot │ + │<──────────────────────────┤ + │ │ + │ 进入 ready 状态 │ +``` + +WebSocket 打开后,客户端必须在 5 秒内发送 `hello`。收到 `welcome` 前不能发送 Agent 消息。 + +### 7.2 hello + +```json +{ + "type": "hello", + "protocol_versions": [1], + "machine_id": "4f1207be-1ce0-4e88-8e3e-e92690567ec8", + "profile": { + "device_name": "Yoko 的 MacBook Pro", + "platform": "macos", + "os_version": "15.5", + "arch": "arm64", + "client_version": "260717.1" + } +} +``` + +`platform` 可取: + +```text +macos +windows +linux +ios +android +``` + +`device_name` 是系统设备名。用户设置的 `alias` 由服务端保存,客户端不能通过 `hello` 覆盖。 + +### 7.3 welcome + +```json +{ + "type": "welcome", + "protocol_version": 1, + "server_time": 1784280000000, + "heartbeat": { + "interval_ms": 30000, + "timeout_ms": 10000 + }, + "limits": { + "max_frame_bytes": 262144, + "max_endpoints": 20 + } +} +``` + +客户端必须使用服务端选择的 `protocol_version` 和返回的限制值。没有共同协议版本时,服务端使用关闭码 `1002`。 + +## 8. 端点目录 + +### 8.1 全量替换 + +服务端只发送完整目录快照,不发送 revision 或增量事件。 + +以下变化都会触发新快照: + +- 端点上线或离线。 +- 端点资料变化。 +- 端点被撤销或恢复。 + +客户端收到快照后必须整体替换本地数组: + +```ts +endpoints = snapshot.endpoints +``` + +客户端不得: + +- 按事件做增量合并。 +- 依赖快照之间的版本号。 +- 保留新快照中已经不存在的端点。 + +### 8.2 directory.snapshot + +```json +{ + "type": "directory.snapshot", + "endpoints": [ + { + "machine_id": "4f1207be-1ce0-4e88-8e3e-e92690567ec8", + "device_name": "Yoko 的 MacBook Pro", + "alias": "办公电脑", + "display_name": "办公电脑", + "platform": "macos", + "os_version": "15.5", + "arch": "arm64", + "client_version": "260717.1", + "protocol_version": 1, + "online": true, + "last_seen_at": 1784280000000 + } + ] +} +``` + +快照规则: + +- 只包含未撤销端点。 +- 包含当前端点自身。 +- 离线端点仍然保留在快照中。 +- `display_name` 已按 `alias ?? device_name` 计算。 + +## 9. Agent 消息 + +### 9.1 通用规则 + +- 每帧是一个 UTF-8 JSON 对象。 +- `payload` 必须是 JSON 对象。 +- `message_id` 使用客户端生成的 UUIDv4。 +- 业务消息只能单播,必须指定 `target`。 +- `method` 由 Agent 定义。 +- 客户端不能发送 `source` 或 `routed_at`。 + +`method` 必须匹配: + +```regex +^[a-z][a-z0-9._-]{0,127}$ +``` + +### 9.2 event + +事件不要求目标返回响应: + +```json +{ + "type": "event", + "message_id": "6ccdf7ee-10c2-4926-86ce-8f9ca82aa2ca", + "target": "dc9e38fe-c928-42b1-b8eb-e8ca41d712fe", + "method": "agent.notice", + "payload": { + "content": "hello" + } +} +``` + +### 9.3 request + +请求要求目标 Agent 返回 `response`: + +```json +{ + "type": "request", + "message_id": "6ccdf7ee-10c2-4926-86ce-8f9ca82aa2ca", + "target": "dc9e38fe-c928-42b1-b8eb-e8ca41d712fe", + "method": "agent.example", + "payload": { + "content": "hello" + } +} +``` + +### 9.4 服务端转发的消息 + +服务端注入可信的 `source` 和 `routed_at`: + +```json +{ + "type": "request", + "message_id": "6ccdf7ee-10c2-4926-86ce-8f9ca82aa2ca", + "source": "4f1207be-1ce0-4e88-8e3e-e92690567ec8", + "target": "dc9e38fe-c928-42b1-b8eb-e8ca41d712fe", + "method": "agent.example", + "routed_at": 1784280000000, + "payload": { + "content": "hello" + } +} +``` + +客户端只能信任服务端转发消息中的 `source`。`routed_at` 是 Unix 毫秒时间,只用于观测,不能用于消息排序。 + +### 9.5 response + +目标 Agent 使用新的 UUIDv4 作为响应 `message_id`,并通过 `reply_to` 引用请求: + +```json +{ + "type": "response", + "message_id": "05cc070a-be53-40b2-b09b-da77d2ea002f", + "target": "4f1207be-1ce0-4e88-8e3e-e92690567ec8", + "reply_to": "6ccdf7ee-10c2-4926-86ce-8f9ca82aa2ca", + "payload": { + "result": "ok" + } +} +``` + +响应不携带 `method`。 + +## 10. 请求状态 + +桥接服务不维护请求状态。客户端必须维护: + +```ts +interface PendingRequest { + messageId: string + target: string + deadline: number + resolve: (payload: Record) => void + reject: (error: Error) => void +} +``` + +发送请求时: + +1. 生成 UUIDv4。 +2. 保存 `message_id`、目标端点和截止时间。 +3. 发送请求。 +4. 等待 `response`、协议错误、断线或本地超时。 + +只有以下条件全部满足时才能完成 pending: + +- `reply_to` 对应仍在等待的请求。 +- `source` 等于原请求的目标端点。 +- `target` 等于当前端点。 +- 响应 `message_id` 是合法且未处理的 UUIDv4。 +- `type` 为 `response`。 + +重复、迟到、来源不匹配或未知 `reply_to` 的响应必须忽略。 + +默认请求超时为 30 秒,调用方可以按方法覆盖。超时或连接中断时返回本地状态: + +```text +outcome_unknown +``` + +`outcome_unknown` 表示无法判断目标是否已经执行,不能当作“未执行”处理。 + +## 11. 重试与去重 + +客户端库不得自动重试 Agent 消息。 + +如果 Agent 明确确认某个方法幂等,可以自行重试,并复用原 `message_id`。桥接服务可能重复投递相同消息,接收端 Agent 负责去重或返回缓存结果。 + +非幂等请求不得自动重试。 + +核心协议不定义取消消息。客户端可以停止本地等待;真正的取消操作由 Agent 定义普通方法。 + +## 12. 顺序保证 + +在双方连接均未中断期间,同一发送端点发往同一目标端点的业务消息按发送顺序到达目标发送队列。 + +客户端不能假设: + +- 不同发送方的消息存在全局顺序。 +- 断线重连后顺序连续。 +- 响应顺序与请求顺序一致。 +- UUIDv4 可以排序。 + +需要更强顺序时,Agent 在 `payload` 中自行定义 `seq`。 + +## 13. 协议错误 + +```json +{ + "type": "error", + "reply_to": "6ccdf7ee-10c2-4926-86ce-8f9ca82aa2ca", + "error": { + "code": "target_offline", + "message": "目标端点当前离线", + "retryable": true, + "retry_after_ms": 1000 + } +} +``` + +`reply_to` 可以为空。`retry_after_ms` 只在服务端能提供建议时出现。 + +| 错误码 | 客户端处理 | +|---|---| +| `invalid_message` | 修复消息结构,不自动重试 | +| `unsupported_protocol` | 提示升级客户端 | +| `unauthorized` | 停止连接并重新登录 | +| `endpoint_revoked` | 停止连接,引导恢复端点 | +| `endpoint_limit_exceeded` | 引导用户撤销旧端点 | +| `target_unavailable` | 刷新 UI,不自动重试 | +| `target_offline` | 提示目标离线 | +| `target_busy` | 提示目标繁忙 | +| `stale_route` | 等待新目录快照,不自动重试业务请求 | +| `rate_limited` | 遵守 `retry_after_ms`,但不自动重试非幂等请求 | +| `payload_too_large` | 减小 JSON 或文件分块 | +| `service_unavailable` | 进入连接退避或提示服务不可用 | + +`retryable=true` 只表示稍后重试可能成功,不代表客户端可以自动重试业务请求。 + +协议错误携带 `reply_to` 时,客户端应结束对应 pending;业务执行结果仍只能由 Agent `response` 表达。 + +## 14. 帧大小与本地文件 + +客户端发送前应按 UTF-8 字节数检查完整 JSON 帧,不能只计算字符串字符数。 + +限制: + +- 原始入站帧最大 256 KiB。 +- 服务端注入字段后的转发帧也必须小于 256 KiB。 +- 不发送 WebSocket 二进制帧。 +- 不依赖 WebSocket 消息压缩。 +- 单个文件最大 5 MiB。 + +文件始终保留在持有它的源端点本地,不上传 OSS 或桥接服务。其他端点查看文件时,文件内容通过 Agent 自定义消息传输。 + +核心协议不定义: + +- 文件方法名。 +- 请求与响应结构。 +- 分块和编码方式。 +- 偏移与顺序。 +- 完整性校验。 +- 路径权限。 +- Agent 业务错误。 + +Agent 自定义的每条文件消息仍必须使用 JSON 对象载荷并遵守 256 KiB 单帧上限。单个文件超过 5 MiB 时必须由 Agent 拒绝,具体错误结构由 Agent 定义。 + +## 15. 心跳 + +服务端使用 WebSocket 原生 Ping/Pong: + +- 每 30 秒发送 Ping。 +- 10 秒内未收到 Pong 时关闭连接。 + +Electron、React Native 的底层 WebSocket 实现会自动回复 Pong。JS 代码: + +- 不需要发送 JSON `ping`。 +- 不需要构造原生 Pong。 +- 不应把业务消息当作心跳。 + +移动应用进入后台并被系统挂起后,连接可能被服务端判定离线,这是预期行为。 + +## 16. 关闭码 + +| 关闭码 | 含义 | 客户端行为 | +|---|---|---| +| `1000` | 正常关闭 | 停止自动重连 | +| `1002` | 协议或版本不兼容 | 停止并提示升级 | +| `1008` | 鉴权或策略违规 | 停止自动重连 | +| `1009` | 消息超过 256 KiB | 停止并修复发送逻辑 | +| `1011` | 服务内部错误 | 自动退避重连 | +| `1012` | 服务重启 | 自动退避重连 | +| `1013` | 服务过载 | 自动退避重连 | +| `4001` | 被同机器标识的新连接替换 | 停止自动重连 | +| `4002` | 端点被撤销 | 停止,引导恢复 | +| `4003` | 登录会话失效 | 停止并重新登录 | + +收到 `4001` 后绝不能自动重连,否则两个使用相同机器标识的客户端会互相替换,形成重连风暴。 + +## 17. 自动重连 + +仅网络中断和 `1011`、`1012`、`1013` 自动重连。 + +建议延迟并加入随机抖动: + +```text +约 0.5s → 1s → 2s → 4s → 8s → 最大 30s +``` + +连续稳定在线 60 秒后重置退避计数。 + +重连成功后: + +- 重新发送 `hello`。 +- 等待新的 `welcome`。 +- 使用新的 `directory.snapshot` 整体替换端点列表。 +- 不自动重发断线前的 Agent 请求。 +- 将断线时尚未完成的请求标记为 `outcome_unknown`。 + +## 18. 推荐 TypeScript 类型 + +```ts +type JsonObject = Record + +interface Hello { + type: "hello" + protocol_versions: number[] + machine_id: string + profile: { + device_name: string + platform: "macos" | "windows" | "linux" | "ios" | "android" + os_version: string + arch: string + client_version: string + } +} + +interface Welcome { + type: "welcome" + protocol_version: number + server_time: number + heartbeat: { + interval_ms: number + timeout_ms: number + } + limits: { + max_frame_bytes: number + max_endpoints: number + } +} + +interface EndpointView { + machine_id: string + device_name: string + alias: string | null + display_name: string + platform: "macos" | "windows" | "linux" | "ios" | "android" + os_version: string + arch: string + client_version: string + protocol_version: number + online: boolean + last_seen_at: number | null +} + +interface DirectorySnapshot { + type: "directory.snapshot" + endpoints: EndpointView[] +} + +interface OutboundEvent { + type: "event" + message_id: string + target: string + method: string + payload: JsonObject +} + +interface OutboundRequest { + type: "request" + message_id: string + target: string + method: string + payload: JsonObject +} + +interface OutboundResponse { + type: "response" + message_id: string + target: string + reply_to: string + payload: JsonObject +} + +type InboundAgentMessage = + | (OutboundEvent & { source: string; routed_at: number }) + | (OutboundRequest & { source: string; routed_at: number }) + | (OutboundResponse & { source: string; routed_at: number }) + +interface ProtocolError { + type: "error" + reply_to?: string | null + error: { + code: string + message: string + retryable: boolean + retry_after_ms?: number + } +} + +type InboundMessage = + | Welcome + | DirectorySnapshot + | InboundAgentMessage + | ProtocolError +``` + +## 19. 客户端分发顺序 + +收到文本帧后建议按以下顺序处理: + +1. 检查帧类型必须是文本。 +2. 解析 JSON。 +3. 检查顶层对象和 `type`。 +4. 根据连接状态限制允许的消息类型。 +5. `directory.snapshot`:整体替换目录。 +6. `response`:严格匹配 pending。 +7. `error`:匹配 pending 或交给连接层处理。 +8. `request/event`:验证 `source/target/message_id` 后交给 Agent。 +9. 未知系统消息只记录不含载荷的告警。 + +客户端日志不得记录完整 `payload`。可以记录: + +```text +source +target +message_id +type +method +payload_size +处理结果 +``` + +## 20. 客户端验收清单 + +- 首次安装生成并安全保存机器标识。 +- 登录后能携带 Cookie 建立 WSS。 +- WebSocket 打开后 5 秒内发送 `hello`。 +- 收到 `welcome` 前不发送 Agent 消息。 +- 每次收到目录快照都整体替换数组。 +- 发送消息前生成 UUIDv4 并检查 UTF-8 帧大小。 +- 不发送 `source`、`routed_at` 或二进制帧。 +- 文件保留在源端点,通过 Agent 自定义消息传输。 +- 文件总大小超过 5 MiB 时拒绝传输。 +- 请求默认 30 秒超时,并严格校验响应来源。 +- 断线时不重发 pending 请求。 +- 不自动重试非幂等消息。 +- 不把 `retryable=true` 当作自动重试授权。 +- 正确处理全部关闭码,尤其是 `4001`、`4002`、`4003`。 +- 自动重连使用带抖动的指数退避。 +- 重连后重新握手并使用全量快照替换目录。 +- 不在日志中输出业务载荷。 + +## 21. 完整设计 + +服务端架构、安全边界和多副本路由设计见 [MonkeyCode 端点桥接协议](./2026-07-17-client-bridge-protocol.md)。 diff --git a/docs/design/2026-07-17-client-bridge-protocol.md b/docs/design/2026-07-17-client-bridge-protocol.md new file mode 100644 index 000000000..b15a7be4f --- /dev/null +++ b/docs/design/2026-07-17-client-bridge-protocol.md @@ -0,0 +1,702 @@ +# MonkeyCode 端点桥接协议 + +## 1. 文档状态 + +- 协议主版本:`1` +- 设计状态:已确认 +- 适用客户端:MonkeyCode 原生桌面端、原生移动端 +- WebSocket 地址:`/api/v1/endpoints/connect` + +本文定义 MonkeyCode 原生客户端之间的发现、鉴权和通用消息路由协议。桥接服务只提供端点目录、在线状态与消息路由,不理解 Agent 的任务、能力或业务载荷。 + +## 2. 目标 + +协议需要满足: + +- 同一 MonkeyCode 用户的原生端点能够互相发现。 +- 手机端、桌面端等端点能够双向传输通用 JSON 消息。 +- 一个端点对应一个 Agent,所有 Agent 在核心协议中地位对等。 +- 桥接服务负责用户鉴权、端点登记、在线状态与路由。 +- 协议支持后端多副本部署。 +- 现有任务流与任务控制 WebSocket 可以继续工作。 + +## 3. 非目标 + +协议首版不提供: + +- 跨用户端点发现或通信。 +- 团队、项目、管理员权限到端点权限的继承。 +- 纯浏览器端点。 +- 一个端点下注册多个 Agent。 +- Agent 能力目录。 +- 任务、会话、工具调用等业务模型。 +- 端到端加密。 +- 离线消息、消息补发或消息持久化。 +- 自动重试、桥接层去重或“恰好一次”执行。 +- WebSocket 二进制帧、超过 5 MiB 的文件传输或消息压缩。 +- 业务广播。 + +具体 Agent 自行定义 `method` 和 `payload`。例如任务列表、任务接续和任务消息同步均属于 Agent 协议,不属于桥接核心协议。 + +## 4. 领域模型 + +### 4.1 端点与 Agent + +端点是一个 MonkeyCode 原生应用安装实例,与一个 Agent 一一对应。任务是 Agent 管理的资源,不参与桥接层寻址。 + +协议寻址键为: + +```text +(user_id, machine_id) +``` + +`machine_id` 由客户端首次安装时生成 UUID,并保存到系统安全存储。应用重启不改变机器标识;卸载重装后产生新机器标识,并被视为新端点。 + +机器标识只用于身份稳定性和路由,不是鉴权凭证。用户身份始终来自现有 MonkeyCode 登录 Cookie。 + +### 4.2 发现域 + +发现域严格限定为同一 `user_id`。团队成员、项目协作者和管理员权限都不能发现或访问其他用户的端点。 + +同一用户的新端点在完成登录和登记后即可进入发现域,无需其他端点确认配对。高风险业务操作是否需要额外确认,由 Agent 协议决定。 + +### 4.3 端点状态 + +端点只有两种持久化管理状态: + +- `active`:允许连接并出现在发现目录中。 +- `revoked`:禁止自动连接,不出现在发现目录中。 + +端点记录永久保留。首版不提供彻底删除。持有有效用户会话的客户端可以显式调用恢复接口,因此撤销是管理状态,不是失窃设备的安全处置边界。处置失窃设备还必须撤销其登录会话。 + +在线状态是临时状态,与持久化管理状态分离: + +- `online`:当前存在有效桥接连接。 +- `offline`:当前不存在有效桥接连接。 + +## 5. 总体架构 + +```mermaid +flowchart LR + A[桌面端 Agent] <-->|WSS| B1[桥接实例 A] + M[移动端 Agent] <-->|WSS| B2[桥接实例 B] + B1 <-->|Pub/Sub| R[(Redis)] + B2 <-->|Pub/Sub| R + B1 --> P[(PostgreSQL)] + B2 --> P + R -. 在线位置与租约 .-> B1 + R -. 在线位置与租约 .-> B2 +``` + +职责划分: + +- PostgreSQL:持久化端点目录、端点资料与撤销状态。 +- Redis:保存在线位置、连接代次、60 秒租约,并承担跨实例 Pub/Sub。 +- 桥接实例:持有本实例 WebSocket、执行鉴权与校验、维护有界发送队列。 +- 客户端:维护请求 pending、超时、响应关联、Agent 业务状态与幂等策略。 + +Redis 不可用时,服务端拒绝新桥接连接或返回 `service_unavailable`,不能降级为仅本实例可见的局部在线状态。 + +## 6. 端点资料 + +客户端在 `hello` 中上报: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `device_name` | string | 操作系统提供的设备名称 | +| `platform` | string | `macos`、`windows`、`linux`、`ios`、`android` | +| `os_version` | string | 操作系统版本 | +| `arch` | string | 例如 `arm64`、`x64` | +| `client_version` | string | MonkeyCode 客户端版本 | + +服务端额外保存: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `alias` | string/null | 用户设置的端点别名 | +| `status` | string | `active` 或 `revoked` | +| `last_seen_at` | integer/null | 最后在线时间,Unix 毫秒 | +| `created_at` | integer | 首次登记时间,Unix 毫秒 | +| `updated_at` | integer | 最后更新时间,Unix 毫秒 | + +展示名称按以下规则计算: + +```text +display_name = alias ?? device_name +``` + +客户端每次连接可以更新 `device_name` 和系统资料,但不能通过 `hello` 覆盖 `alias`。 + +不上传用户名、IP、MAC、硬盘序列号、硬件指纹或完整硬件清单。 + +## 7. HTTP 接口 + +所有接口都使用现有 MonkeyCode 登录 Cookie 鉴权,用户范围从会话推导,不出现在路径参数中。 + +| 方法 | 路径 | 用途 | +|---|---|---| +| `GET` | `/api/v1/endpoints` | 查询当前用户全部端点,包括已撤销端点 | +| `GET` | `/api/v1/endpoints/{machine_id}` | 查询一个端点 | +| `PATCH` | `/api/v1/endpoints/{machine_id}` | 修改端点别名 | +| `POST` | `/api/v1/endpoints/{machine_id}/revoke` | 撤销端点并关闭其连接 | +| `POST` | `/api/v1/endpoints/{machine_id}/restore` | 恢复已撤销端点 | +| `GET` | `/api/v1/endpoints/connect` | WebSocket Upgrade | + +HTTP 接口只能读取或修改当前用户自己的端点。不存在、属于其他用户或不可见的机器标识统一按资源不存在处理。 + +每个用户默认最多拥有 20 个未撤销端点。达到上限后,新机器标识无法完成连接登记;已有端点重连不受影响。上限允许私有化部署通过配置调整,服务端不得自动淘汰旧端点。 + +## 8. WebSocket 传输约束 + +### 8.1 连接安全 + +- 生产环境只允许 `wss://`。 +- 开发模式仅允许 `localhost`、`127.0.0.1` 和 `[::1]` 使用 `ws://`。 +- 升级请求使用现有 MonkeyCode 登录 Cookie 鉴权。 +- 请求携带非空 `Origin` 时,必须匹配配置的可信来源白名单。 +- 原生客户端可以不携带 `Origin`,但仍必须具有有效 Cookie。 +- 新连接不能复用当前公共 WebSocket 工具中的宽松跨域设置。 + +### 8.2 帧格式 + +- 每个 WebSocket 文本帧承载一个完整 UTF-8 JSON 对象。 +- 不使用 Base64 包装 JSON。 +- 不接受 WebSocket 二进制帧。 +- 禁用 `permessage-deflate`。 +- 原始入站帧与规范化、注入服务端字段后的转发帧都不得超过 `256 KiB`。 +- 单个本地文件最大 `5 MiB`,通过 Agent 自定义协议传输。 + +原始入站帧超过限制时使用 WebSocket 关闭码 `1009` 断开连接,不继续解析。原始帧未超限、但规范化后的转发帧超过限制时返回 `payload_too_large`,连接可以继续使用。 + +文件的方法名、分块、编码、校验和错误结构不属于桥接核心协议。Agent 产生的每个消息仍需遵守 JSON 对象载荷和 256 KiB 单帧上限。 + +### 8.3 单连接 + +一个端点同一时刻只允许一条有效连接。新连接鉴权和登记成功后,原子替换旧连接;旧连接收到关闭码 `4001`。 + +目录快照、协议错误和 Agent 业务消息全部复用这一条 WebSocket,不为任务、方法或目标端点创建额外连接。 + +## 9. 握手 + +### 9.1 连接流程 + +```mermaid +sequenceDiagram + participant C as 客户端 + participant B as 桥接服务 + participant S as 会话存储 + participant D as 端点目录 + participant R as Redis + + C->>B: GET /api/v1/endpoints/connect + Cookie + B->>S: 校验用户会话 + S-->>B: user_id + B-->>C: 101 Switching Protocols + C->>B: hello + B->>D: 查询或登记 (user_id, machine_id) + B->>R: 原子登记实例、连接代次和 60 秒租约 + B-->>C: welcome + B-->>C: directory.snapshot +``` + +客户端必须在升级完成后 5 秒内发送 `hello`,收到 `welcome` 前不能发送 Agent 业务消息。 + +### 9.2 hello + +```json +{ + "type": "hello", + "protocol_versions": [1], + "machine_id": "4f1207be-1ce0-4e88-8e3e-e92690567ec8", + "profile": { + "device_name": "Yoko 的 MacBook Pro", + "platform": "macos", + "os_version": "15.5", + "arch": "arm64", + "client_version": "260717.1" + } +} +``` + +规则: + +- `machine_id` 必须是规范 UUID。 +- `protocol_versions` 是客户端支持的整数主版本列表。 +- 服务端选择双方共同支持的最高版本。 +- 没有共同版本时使用关闭码 `1002`。 +- 新增可选字段保持向后兼容;删除字段、修改字段含义或交互语义需要新主版本。 + +### 9.3 welcome + +```json +{ + "type": "welcome", + "protocol_version": 1, + "server_time": 1784280000000, + "heartbeat": { + "interval_ms": 30000, + "timeout_ms": 10000 + }, + "limits": { + "max_frame_bytes": 262144, + "max_endpoints": 20 + } +} +``` + +## 10. 端点目录 + +### 10.1 全量快照 + +服务端只发送全量目录快照,不定义 revision、增量补丁、`upsert` 或 `remove`。 + +发送时机: + +- 当前连接收到 `welcome` 后。 +- 同一用户任一端点上线。 +- 同一用户任一端点离线。 +- 端点资料发生变化。 +- 端点被撤销。 +- 端点被恢复。 + +每次变化后,服务端向该用户全部在线端点发送新的完整快照。客户端必须使用新数组整体替换旧数组,不做增量合并。 + +### 10.2 directory.snapshot + +```json +{ + "type": "directory.snapshot", + "endpoints": [ + { + "machine_id": "4f1207be-1ce0-4e88-8e3e-e92690567ec8", + "device_name": "Yoko 的 MacBook Pro", + "alias": "办公电脑", + "display_name": "办公电脑", + "platform": "macos", + "os_version": "15.5", + "arch": "arm64", + "client_version": "260717.1", + "protocol_version": 1, + "online": true, + "last_seen_at": 1784280000000 + } + ] +} +``` + +WebSocket 快照只包含未撤销端点,并包含当前端点自身。HTTP 管理接口仍返回已撤销端点。 + +## 11. Agent 消息 + +### 11.1 消息类型 + +核心协议定义: + +- `event`:单向 Agent 消息,不要求响应。 +- `request`:要求目标 Agent 返回业务响应。 +- `response`:引用一个请求的业务响应。 + +取消执行不属于核心协议。客户端可以停止本地等待;真正的业务取消由 Agent 定义普通方法。 + +### 11.2 method + +`event` 和 `request` 必须携带 Agent 自定义 `method`,满足: + +```regex +^[a-z][a-z0-9._-]{0,127}$ +``` + +桥接服务只校验格式,不维护方法目录,也不理解方法语义。 + +### 11.3 payload + +`payload` 必须是 JSON 对象。无参数时使用 `{}`,不允许顶层字符串、数字或数组。 + +### 11.4 message_id + +每条 Agent 消息由发送客户端生成新的 UUIDv4 `message_id`。消息标识不承载时间或顺序语义。 + +### 11.5 请求与事件 + +客户端发送: + +```json +{ + "type": "request", + "message_id": "6ccdf7ee-10c2-4926-86ce-8f9ca82aa2ca", + "target": "dc9e38fe-c928-42b1-b8eb-e8ca41d712fe", + "method": "agent.example", + "payload": { + "content": "hello" + } +} +``` + +桥接服务转发: + +```json +{ + "type": "request", + "message_id": "6ccdf7ee-10c2-4926-86ce-8f9ca82aa2ca", + "source": "4f1207be-1ce0-4e88-8e3e-e92690567ec8", + "target": "dc9e38fe-c928-42b1-b8eb-e8ca41d712fe", + "method": "agent.example", + "routed_at": 1784280000000, + "payload": { + "content": "hello" + } +} +``` + +`event` 使用相同结构,只将 `type` 改为 `event`。 + +客户端不能上报 `source` 或 `routed_at`。发送方来源由桥接服务根据已鉴权连接注入。客户端伪造服务端字段时返回 `invalid_message`。 + +### 11.6 响应 + +```json +{ + "type": "response", + "message_id": "05cc070a-be53-40b2-b09b-da77d2ea002f", + "target": "4f1207be-1ce0-4e88-8e3e-e92690567ec8", + "reply_to": "6ccdf7ee-10c2-4926-86ce-8f9ca82aa2ca", + "payload": { + "result": "ok" + } +} +``` + +`response` 不重复 `method`。桥接服务仍会注入 `source` 和 `routed_at`。 + +### 11.7 顶层字段处理 + +- 桥接服务只构造并转发规范定义的顶层字段。 +- 未知顶层字段被忽略并丢弃。 +- `payload` 内字段保持原样。 +- `source`、`routed_at` 等服务端专属字段由客户端上报时直接报错。 + +### 11.8 本地文件边界 + +文件始终保留在源端点本地,桥接服务不上传、不保存文件。其他端点通过 Agent 自定义消息访问文件。 + +核心协议只约束: + +- 单个文件不得超过 `5 MiB`。 +- 文件内容必须通过本协议的 Agent 消息传输。 +- 每条消息必须遵守 JSON 对象载荷和 `256 KiB` 单帧上限。 + +核心协议不定义文件方法名、请求与响应结构、分块、编码、偏移、完整性校验、访问控制或业务错误,这些内容全部由 Agent 自行定义。 + +## 12. 请求状态与响应匹配 + +桥接服务不维护 Agent 请求状态。通用客户端库负责: + +- 按 `message_id` 保存 pending 请求。 +- 默认等待 30 秒,调用方可以按方法覆盖。 +- 使用 `reply_to` 匹配响应。 +- 超时后删除 pending,并返回本地状态 `outcome_unknown`。 +- 迟到响应不恢复已经超时的请求。 + +客户端只有在以下条件全部满足时才能接受响应: + +- `reply_to` 对应仍在等待的请求。 +- `source` 等于原请求指定的目标端点。 +- `target` 等于当前端点。 +- 响应 `message_id` 是合法且未处理过的 UUIDv4。 +- `type` 为 `response`。 + +重复、迟到或来源不匹配的响应被忽略,只记录不含载荷的元数据告警。 + +## 13. 路由与可靠性语义 + +### 13.1 单播与对等 + +Agent 业务消息只能单播,发送方必须明确指定目标机器标识。桥接层不区分控制端和被控端,所有端点完全对等。 + +端点目录快照由桥接服务发送,不属于 Agent 业务广播。 + +### 13.2 在线路由 + +桥接服务只向当前在线的目标连接路由消息,不保存消息供离线端点上线后补发。 + +### 13.3 无成功回执 + +桥接服务不发送成功送达确认。服务端成功写入目标连接也不能被发送方解释为 Agent 已收到或已执行。 + +- 可在路由阶段确定的失败通过协议错误返回。 +- Agent 业务执行结果只能通过 `response` 表达。 +- 请求超时表示结果未知,不表示目标未执行。 + +### 13.4 重试与去重 + +- 桥接服务不自动重试。 +- 通用客户端库默认不自动重试。 +- 桥接服务不按 `message_id` 去重。 +- 相同 `message_id` 可能被重复投递。 +- 只有 Agent 明确确认操作幂等时才能自行重试,并复用原消息标识。 +- 接收端 Agent 负责幂等请求的去重或结果缓存。 + +### 13.5 顺序 + +在双方连接均未中断期间,桥接服务保证同一发送端点到同一目标端点的业务消息按发送顺序进入目标发送队列。 + +不保证: + +- 不同发送方之间的全局顺序。 +- 断线重连前后的连续顺序。 +- 响应顺序与请求顺序一致。 +- 按 `message_id` 排序。 + +需要更强顺序的 Agent 协议自行定义 `seq`。 + +## 14. 协议错误 + +桥接服务产生的协议错误与 Agent 业务响应分开。 + +```json +{ + "type": "error", + "reply_to": "6ccdf7ee-10c2-4926-86ce-8f9ca82aa2ca", + "error": { + "code": "target_offline", + "message": "目标端点当前离线", + "retryable": true, + "retry_after_ms": 1000 + } +} +``` + +`reply_to` 在错误由握手或无消息标识的系统操作触发时可以为空。`retry_after_ms` 仅在服务端能给出合理建议时出现。 + +核心错误码: + +| 错误码 | 含义 | +|---|---| +| `invalid_message` | JSON 或信封不符合协议 | +| `unsupported_protocol` | 没有共同协议主版本 | +| `unauthorized` | 用户会话无效 | +| `endpoint_revoked` | 当前端点已被撤销 | +| `endpoint_limit_exceeded` | 用户未撤销端点达到上限 | +| `target_unavailable` | 目标不存在、属于其他用户或已撤销 | +| `target_offline` | 当前用户目录中的目标离线 | +| `target_busy` | 目标连接发送队列已满 | +| `stale_route` | 目标连接代次已变化 | +| `rate_limited` | 用户或端点触发限流 | +| `payload_too_large` | 规范化消息超过大小上限 | +| `service_unavailable` | Redis 或必要路由组件不可用 | + +目标错误需要避免信息泄露: + +- 不存在、属于其他用户或已撤销统一返回 `target_unavailable`。 +- 只有同一用户目录中明确存在的离线端点才返回 `target_offline`。 + +`retryable=true` 只表示稍后重试可能成功,不授权客户端自动重试非幂等业务请求。 + +## 15. 心跳、会话与在线租约 + +### 15.1 WebSocket Ping/Pong + +- 服务端每 30 秒发送 WebSocket 原生 Ping。 +- 10 秒内未收到对应 Pong 时关闭连接。 +- Electron、React Native 底层 WebSocket 自动回复 Pong,JS 业务代码无需处理。 +- 不定义 JSON 心跳消息。 + +### 15.2 会话复验 + +每次心跳时同时重新检查 Redis 中的登录会话。会话过期、退出或被管理员撤销时,连接使用关闭码 `4003` 关闭。 + +### 15.3 在线租约 + +- Redis 在线租约 TTL 为 60 秒。 +- 每次成功收到 Pong 后续租。 +- 正常断开立即删除当前连接代次的租约并广播新目录快照。 +- 实例异常退出时,后台协调器在租约过期后将端点判定为离线并触发全量目录快照。 +- 路由发现目标实例不可达时立即返回 `target_unavailable`,不等待租约过期。 + +## 16. 多副本路由 + +### 16.1 在线位置 + +Redis 在线位置至少包含: + +```text +user_id +machine_id +instance_id +connection_generation +lease_deadline +``` + +每次端点建立新连接时生成新的连接代次。登记新连接、替换旧连接和写入在线位置必须是原子操作。 + +### 16.2 跨实例消息 + +发送实例: + +1. 根据 `(user_id, target_machine_id)` 查询 Redis 在线位置。 +2. 同实例目标直接进入本地有界队列。 +3. 跨实例目标通过目标 `instance_id` 的 Redis Pub/Sub 通道发送。 +4. Pub/Sub 消息携带目标 `connection_generation`。 + +目标实例只有在本地当前连接代次完全匹配时才能投递,否则返回 `stale_route`。桥接层不自动重新查找或重试。 + +### 16.3 连接替换 + +新连接替换旧连接时: + +1. Redis 原子写入新实例与新连接代次。 +2. 通知旧实例关闭旧连接。 +3. 旧实例清理时只能删除仍与自己连接代次匹配的租约。 +4. 途中携带旧连接代次的消息被拒绝。 + +这可以避免跨实例竞态将消息投递给已被替换的旧 Agent。 + +## 17. 背压、优先级与限流 + +### 17.1 有界发送队列 + +每条连接使用同时限制消息数量和总字节数的有界队列。具体阈值由部署配置决定,不写入协议版本。 + +队列已满时: + +- 当前业务消息不入队。 +- 向发送方返回 `target_busy`。 +- 不阻塞整个桥接实例。 +- 不静默丢弃消息。 + +目标端点长期无法排空队列时,服务端关闭其连接并更新在线状态。 + +### 17.2 消息优先级 + +优先级从高到低: + +1. WebSocket Ping/Pong 和 Close 控制帧。 +2. 协议错误、撤销通知和目录快照。 +3. Agent 业务消息。 + +高优先级消息可以插队,但调度器需要限制连续高优先级发送数量,避免业务消息永久饥饿。同一来源到同一目标的业务消息之间仍保持 FIFO。 + +### 17.3 双层限流 + +服务端同时按用户和发送端点限制: + +- 单位时间消息数。 +- 单位时间载荷总字节数。 + +具体阈值属于部署配置。首次超限返回 `rate_limited` 和可选 `retry_after_ms`;持续超限时使用关闭码 `1008`。 + +## 18. WebSocket 关闭码 + +| 关闭码 | 含义 | 自动重连 | +|---|---|---| +| `1000` | 正常关闭 | 否 | +| `1002` | 协议或版本不兼容 | 否 | +| `1008` | 鉴权或策略违规 | 否 | +| `1009` | 消息超过 256 KiB | 否 | +| `1011` | 服务内部错误 | 是 | +| `1012` | 服务重启 | 是 | +| `1013` | 服务过载 | 是 | +| `4001` | 被同机器标识的新连接替换 | 否 | +| `4002` | 端点被撤销 | 否 | +| `4003` | 登录会话失效 | 否 | + +## 19. 客户端重连 + +网络中断以及 `1011`、`1012`、`1013` 使用指数退避并加入随机抖动: + +```text +约 0.5s → 1s → 2s → 4s → 8s → 最大 30s +``` + +稳定在线 60 秒后重置退避计数。 + +`1000`、`1002`、`1008`、`1009`、`4001`、`4002`、`4003` 不自动重连。特别是 `4001` 必须停止自动重连,避免两个复制同一机器标识的客户端互相替换形成重连风暴。 + +## 20. 优雅停机 + +桥接实例进入停机状态后: + +1. 停止接受新 WebSocket。 +2. 不再接收新的 Agent 业务消息。 +3. 向现有连接发送 `1012 Service Restart`。 +4. 只删除与本实例、当前连接代次匹配的 Redis 租约。 +5. 最多等待 10 秒完成关闭,然后退出。 + +客户端按重连退避策略连接其他实例。 + +## 21. 安全与隐私 + +### 21.1 服务端可见性 + +首版只提供 WSS 传输加密,不提供端到端加密。桥接服务会解析信封,并且技术上能够接触载荷字节,但不能解释 Agent 业务语义。 + +### 21.2 日志 + +任何环境和日志级别都不得记录 `payload` 原文。日志、指标和安全审计只允许记录: + +```text +user_id +source_machine_id +target_machine_id +message_id +type +method +payload_size +route_result +latency +``` + +端点登记、撤销、恢复、连接替换、会话失效和鉴权失败进入安全审计日志。 + +### 21.3 授权校验 + +每次路由都必须根据连接上已鉴权的 `user_id` 检查目标,不信任客户端声明的用户或来源。管理员权限、团队角色和项目协作权限不能绕过发现域。 + +## 22. 与现有系统的关系 + +新协议以新增方式提供,不立即替换: + +- `/api/v1/users/tasks/stream` +- `/api/v1/users/tasks/control` +- 其他现有任务与终端 WebSocket + +第一阶段只完成端点发现和通用 Agent 消息通道。任务接续能力可以在后续作为 Agent 自定义协议实现,并逐步复用桥接通道。 + +## 23. 验收场景 + +实现至少覆盖以下场景: + +1. 同一用户的桌面端和移动端分别连接不同后端副本,能够互相发现并双向路由。 +2. 不同用户即使知道对方机器标识,也只能得到 `target_unavailable`。 +3. 新连接使用相同机器标识时替换旧连接,旧连接停止自动重连。 +4. 端点上线、离线、改名、撤销和恢复后,所有在线端点都收到新的完整目录快照。 +5. 被撤销端点立即断开且不能自动登记;显式恢复后可以重新连接。 +6. 用户退出登录后,桥接连接在一个心跳周期内关闭。 +7. 超过 256 KiB 的消息使用 `1009` 关闭连接。 +8. 目标离线、目标队列已满、路由代次过期时返回对应协议错误。 +9. 同一来源到同一目标的业务消息在连续连接期间保持 FIFO。 +10. 请求超时返回客户端本地 `outcome_unknown`,桥接服务不重试。 +11. 重复 `message_id` 被桥接服务正常转发,接收端负责幂等。 +12. Redis 不可用时新连接和跨实例路由失败关闭,不出现局部假在线。 +13. 后端实例异常退出后,端点最迟在 60 秒租约过期后变为离线。 +14. 滚动发布时客户端收到 `1012` 并按带抖动的退避策略重连。 +15. 日志、指标、错误与审计中不出现业务载荷。 +16. 小于等于 5 MiB 的本地文件可以通过 Agent 自定义消息传输,桥接服务不持久化文件。 +17. 超过 5 MiB 的文件被 Agent 拒绝,具体错误结构由 Agent 定义。 + +## 24. 相关架构决策 + +- [端点发现限定于同一用户](../adr/0001-端点发现限定于同一用户.md) +- [使用安装级随机标识识别端点](../adr/0002-使用安装级随机标识识别端点.md) +- [首版不提供端到端加密](../adr/0003-首版不提供端到端加密.md) +- [桥接服务仅提供在线路由](../adr/0004-桥接服务仅提供在线路由.md) +- [每个端点只保留一条有效连接](../adr/0005-每个端点只保留一条有效连接.md) +- [端点鉴权复用用户会话](../adr/0006-端点鉴权复用用户会话.md) +- [端点与 Agent 一一对应](../adr/0007-端点与Agent一一对应.md) +- [请求状态由客户端维护](../adr/0008-请求状态由客户端维护.md) +- [核心协议不自动重试业务消息](../adr/0009-核心协议不自动重试业务消息.md) +- [核心协议使用 JSON 文本帧](../adr/0010-核心协议使用JSON文本帧.md) +- [桥接连接校验 WebSocket 来源](../adr/0011-桥接连接校验WebSocket来源.md) +- [桥接服务支持多副本路由](../adr/0012-桥接服务支持多副本路由.md) +- [桥接服务不记录业务载荷](../adr/0013-桥接服务不记录业务载荷.md) +- [文件保留在源端点本地](../adr/0014-文件保留在源端点本地.md) diff --git a/docs/superpowers/plans/2026-07-14-skill-search.md b/docs/plans/2026-07-14-skill-search.md similarity index 100% rename from docs/superpowers/plans/2026-07-14-skill-search.md rename to docs/plans/2026-07-14-skill-search.md diff --git a/docs/superpowers/plans/2026-07-15-task-skill-wheel-scroll.md b/docs/plans/2026-07-15-task-skill-wheel-scroll.md similarity index 100% rename from docs/superpowers/plans/2026-07-15-task-skill-wheel-scroll.md rename to docs/plans/2026-07-15-task-skill-wheel-scroll.md diff --git a/frontend/src/components/app-runtime-provider.tsx b/frontend/src/components/app-runtime-provider.tsx index d8a9d4f09..9da97b11f 100644 --- a/frontend/src/components/app-runtime-provider.tsx +++ b/frontend/src/components/app-runtime-provider.tsx @@ -13,6 +13,7 @@ import { type DomainUser, type GithubComChaitinMonkeyCodeBackendDomainServerConfig, } from "@/api/Api"; +import { DesktopEndpointBridge } from "@/components/desktop-endpoint-bridge"; export type ServerConfig = GithubComChaitinMonkeyCodeBackendDomainServerConfig; export type RuntimeAuthStatus = "unknown" | "authenticated" | "anonymous"; @@ -131,6 +132,7 @@ export function AppRuntimeProvider({ children }: { children: ReactNode }) { return ( + {children} ); diff --git a/frontend/src/components/desktop-endpoint-bridge.tsx b/frontend/src/components/desktop-endpoint-bridge.tsx new file mode 100644 index 000000000..bf53233aa --- /dev/null +++ b/frontend/src/components/desktop-endpoint-bridge.tsx @@ -0,0 +1,14 @@ +import { useEffect } from "react"; +import { desktopEndpointClient } from "@/lib/desktop-endpoint-client"; + +export function DesktopEndpointBridge({ authenticated }: { authenticated: boolean }) { + useEffect(() => { + if (!authenticated || !window.monkeyCodeDesktop?.endpointBridge) return; + void desktopEndpointClient.start(window.location.origin).catch(() => undefined); + return () => { + void desktopEndpointClient.stop(); + }; + }, [authenticated]); + + return null; +} diff --git a/frontend/src/lib/desktop-endpoint-client.ts b/frontend/src/lib/desktop-endpoint-client.ts new file mode 100644 index 000000000..e20dba08b --- /dev/null +++ b/frontend/src/lib/desktop-endpoint-client.ts @@ -0,0 +1,190 @@ +type JsonObject = Record; + +export type DesktopEndpoint = { + machine_id: string; + display_name: string; + online?: boolean; +}; + +export type DesktopAgentMessage = { + type: "event" | "request"; + message_id: string; + source: string; + target: string; + method: string; + routed_at: number; + payload: JsonObject; +}; + +type Pending = { + target: string; + timer: ReturnType; + resolve: (payload: JsonObject) => void; + reject: (error: DesktopEndpointError) => void; +}; + +export class DesktopEndpointError extends Error { + constructor(public code: string, message: string) { + super(message); + this.name = "DesktopEndpointError"; + } +} + +class DesktopEndpointClient { + private machineId = ""; + private pending = new Map(); + private removeMessageListener: (() => void) | null = null; + private removeCloseListener: (() => void) | null = null; + private directoryListeners = new Set<(endpoints: DesktopEndpoint[]) => void>(); + private agentListeners = new Set<(message: DesktopAgentMessage) => void>(); + + async start(baseUrl: string): Promise { + const bridge = window.monkeyCodeDesktop?.endpointBridge; + if (!bridge) return; + await this.stop(); + this.removeMessageListener = bridge.onMessage((message) => this.handleMessage(message)); + this.removeCloseListener = bridge.onClose(() => this.finishPendingAsUnknown()); + try { + const result = await bridge.start(baseUrl); + this.machineId = result.machine_id; + } catch (error) { + this.removeMessageListener(); + this.removeCloseListener(); + this.removeMessageListener = null; + this.removeCloseListener = null; + throw error; + } + } + + async stop(): Promise { + this.removeMessageListener?.(); + this.removeCloseListener?.(); + this.removeMessageListener = null; + this.removeCloseListener = null; + this.finishPendingAsUnknown(); + await window.monkeyCodeDesktop?.endpointBridge.stop(); + } + + subscribeDirectory(listener: (endpoints: DesktopEndpoint[]) => void): () => void { + this.directoryListeners.add(listener); + return () => this.directoryListeners.delete(listener); + } + + subscribeAgent(listener: (message: DesktopAgentMessage) => void): () => void { + this.agentListeners.add(listener); + return () => this.agentListeners.delete(listener); + } + + async event(target: string, method: string, payload: JsonObject): Promise { + const messageId = crypto.randomUUID(); + await this.send({ type: "event", message_id: messageId, target, method, payload }); + return messageId; + } + + request(target: string, method: string, payload: JsonObject, timeoutMs = 30_000): Promise { + const messageId = crypto.randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(messageId); + reject(new DesktopEndpointError("outcome_unknown", "请求结果未知")); + }, timeoutMs); + this.pending.set(messageId, { target, timer, resolve, reject }); + void this.send({ type: "request", message_id: messageId, target, method, payload }).catch((error) => { + clearTimeout(timer); + this.pending.delete(messageId); + reject(error); + }); + }); + } + + async respond(target: string, replyTo: string, payload: JsonObject): Promise { + const messageId = crypto.randomUUID(); + await this.send({ type: "response", message_id: messageId, target, reply_to: replyTo, payload }); + return messageId; + } + + private async send(message: JsonObject): Promise { + const bridge = window.monkeyCodeDesktop?.endpointBridge; + if (!bridge) throw new DesktopEndpointError("not_available", "桌面端桥接不可用"); + await bridge.send(JSON.stringify(message)); + } + + private handleMessage(raw: string): void { + let message: JsonObject; + try { + message = JSON.parse(raw) as JsonObject; + } catch { + return; + } + if (message.type === "directory.snapshot" && Array.isArray(message.endpoints)) { + const endpoints = message.endpoints.filter(isEndpoint); + for (const listener of this.directoryListeners) listener([...endpoints]); + return; + } + if (message.type === "response") { + this.handleResponse(message); + return; + } + if (message.type === "error") { + this.handleError(message); + return; + } + if (isAgentMessage(message) && message.target === this.machineId) { + for (const listener of this.agentListeners) listener(message); + } + } + + private handleResponse(message: JsonObject): void { + if ( + typeof message.reply_to !== "string" || + typeof message.source !== "string" || + message.target !== this.machineId || + !isObject(message.payload) + ) return; + const pending = this.pending.get(message.reply_to); + if (!pending || pending.target !== message.source) return; + clearTimeout(pending.timer); + this.pending.delete(message.reply_to); + pending.resolve(message.payload); + } + + private handleError(message: JsonObject): void { + if (typeof message.reply_to !== "string" || !isObject(message.error)) return; + const pending = this.pending.get(message.reply_to); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.reply_to); + pending.reject(new DesktopEndpointError( + typeof message.error.code === "string" ? message.error.code : "protocol_error", + typeof message.error.message === "string" ? message.error.message : "桥接协议错误", + )); + } + + private finishPendingAsUnknown(): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new DesktopEndpointError("outcome_unknown", "请求结果未知")); + } + this.pending.clear(); + } +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isEndpoint(value: unknown): value is DesktopEndpoint { + return isObject(value) && typeof value.machine_id === "string" && typeof value.display_name === "string"; +} + +function isAgentMessage(value: JsonObject): value is JsonObject & DesktopAgentMessage { + return (value.type === "event" || value.type === "request") && + typeof value.message_id === "string" && + typeof value.source === "string" && + typeof value.target === "string" && + typeof value.method === "string" && + typeof value.routed_at === "number" && + isObject(value.payload); +} + +export const desktopEndpointClient = new DesktopEndpointClient(); diff --git a/frontend/src/types/desktop.d.ts b/frontend/src/types/desktop.d.ts new file mode 100644 index 000000000..e48584165 --- /dev/null +++ b/frontend/src/types/desktop.d.ts @@ -0,0 +1,15 @@ +export {}; + +declare global { + interface Window { + monkeyCodeDesktop?: { + endpointBridge: { + start: (baseUrl: string) => Promise<{ machine_id: string }>; + stop: () => Promise; + send: (message: string) => Promise; + onMessage: (listener: (message: string) => void) => () => void; + onClose: (listener: (detail: { code: number; reason: string }) => void) => () => void; + }; + }; + } +} diff --git a/mobile/app.json b/mobile/app.json index 7285f7fbf..84cdff74d 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -92,7 +92,8 @@ "buildReactNativeFromSource": true } } - ] + ], + "expo-secure-store" ], "experiments": { "typedRoutes": true diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index f12a74772..16a92e8e3 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -9,6 +9,7 @@ import { LoadingView } from '@/components/ui'; import { PreviewProvider } from '@/components/PreviewProvider'; import { ThemeProvider, useTheme } from '@/theme'; import { applyOta, useOtaAutoUpdate } from '@/updates/useOtaUpdate'; +import { EndpointBridgeProvider } from '@/api/EndpointBridgeContext'; function RootNav() { const { ready, authenticated } = useAuth(); @@ -86,7 +87,9 @@ export default function RootLayout() { - + + + diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 38e481c51..a4ad75dba 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -19,6 +19,7 @@ "expo-build-properties": "~55.0.15", "expo-clipboard": "~55.0.14", "expo-constants": "~55.0.16", + "expo-device": "~55.0.19", "expo-document-picker": "~55.0.14", "expo-file-system": "~55.0.23", "expo-font": "~55.0.8", @@ -28,6 +29,7 @@ "expo-linking": "~55.0.16", "expo-media-library": "~55.0.18", "expo-router": "~55.0.16", + "expo-secure-store": "~55.0.16", "expo-sharing": "~55.0.21", "expo-splash-screen": "~55.0.22", "expo-status-bar": "~55.0.6", @@ -7238,6 +7240,44 @@ "react-native": "*" } }, + "node_modules/expo-device": { + "version": "55.0.19", + "resolved": "https://registry.npmmirror.com/expo-device/-/expo-device-55.0.19.tgz", + "integrity": "sha512-BYEr3oyjtnXk432usxavftybCtucelCQD1QGIo2zruxeT/hzoGe36uclOC49a+tYFQoybqOylSV2035H7kDIPg==", + "license": "MIT", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device/node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmmirror.com/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/expo-document-picker": { "version": "55.0.14", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-55.0.14.tgz", @@ -7514,6 +7554,15 @@ "node": ">=10" } }, + "node_modules/expo-secure-store": { + "version": "55.0.16", + "resolved": "https://registry.npmmirror.com/expo-secure-store/-/expo-secure-store-55.0.16.tgz", + "integrity": "sha512-4DkdluBLJnqndKMpvg4bTVoyi1D5p3V7nxyYvlzLvuqMVrUzXPTGTyIw347H3wVU1jp+sfGNX5xqtpQCY3hF+A==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "55.0.11", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-55.0.11.tgz", diff --git a/mobile/package.json b/mobile/package.json index 3ee6368a9..4afce242d 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -23,6 +23,7 @@ "expo-build-properties": "~55.0.15", "expo-clipboard": "~55.0.14", "expo-constants": "~55.0.16", + "expo-device": "~55.0.19", "expo-document-picker": "~55.0.14", "expo-file-system": "~55.0.23", "expo-font": "~55.0.8", @@ -32,6 +33,7 @@ "expo-linking": "~55.0.16", "expo-media-library": "~55.0.18", "expo-router": "~55.0.16", + "expo-secure-store": "~55.0.16", "expo-sharing": "~55.0.21", "expo-splash-screen": "~55.0.22", "expo-status-bar": "~55.0.6", diff --git a/mobile/src/api/EndpointBridgeContext.tsx b/mobile/src/api/EndpointBridgeContext.tsx new file mode 100644 index 000000000..fee34a233 --- /dev/null +++ b/mobile/src/api/EndpointBridgeContext.tsx @@ -0,0 +1,76 @@ +import Constants from 'expo-constants'; +import * as Device from 'expo-device'; +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { Platform } from 'react-native'; + +import { useAuth } from '@/auth/AuthContext'; +import { + EndpointBridgeClient, + type EndpointConnectionState, + type EndpointPlatform, + type EndpointView, +} from './endpointBridge'; +import { getOrCreateEndpointMachineId } from './endpointIdentity'; + +interface EndpointBridgeState { + client: EndpointBridgeClient | null; + state: EndpointConnectionState; + endpoints: EndpointView[]; +} + +const EndpointBridgeContext = createContext({ + client: null, + state: 'idle', + endpoints: [], +}); + +export function EndpointBridgeProvider({ children }: { children: React.ReactNode }) { + const { ready, authenticated, baseUrl } = useAuth(); + const [client, setClient] = useState(null); + const [state, setState] = useState('idle'); + const [endpoints, setEndpoints] = useState([]); + + useEffect(() => { + if (!ready || !authenticated || Platform.OS === 'web') { + setClient(null); + setState('idle'); + setEndpoints([]); + return; + } + let cancelled = false; + let active: EndpointBridgeClient | null = null; + void getOrCreateEndpointMachineId().then((machineId) => { + if (cancelled) return; + const platform = Platform.OS as EndpointPlatform; + active = new EndpointBridgeClient({ + machineId, + profile: { + device_name: Device.deviceName || Device.modelName || `${platform} 端点`, + platform, + os_version: String(Platform.Version), + arch: Device.supportedCpuArchitectures?.join(',') || 'unknown', + client_version: Constants.expoConfig?.version || 'unknown', + }, + onState: setState, + onDirectory: setEndpoints, + }); + setClient(active); + active.connect(); + }).catch(() => { + if (!cancelled) setState('stopped'); + }); + return () => { + cancelled = true; + active?.disconnect(); + setClient(null); + setEndpoints([]); + }; + }, [ready, authenticated, baseUrl]); + + const value = useMemo(() => ({ client, state, endpoints }), [client, state, endpoints]); + return {children}; +} + +export function useEndpointBridge(): EndpointBridgeState { + return useContext(EndpointBridgeContext); +} diff --git a/mobile/src/api/__tests__/endpointBridge.test.ts b/mobile/src/api/__tests__/endpointBridge.test.ts new file mode 100644 index 000000000..382758483 --- /dev/null +++ b/mobile/src/api/__tests__/endpointBridge.test.ts @@ -0,0 +1,162 @@ +const mockOpenWebSocket = jest.fn(); + +jest.mock('../client', () => ({ + getBaseUrl: () => 'https://api.example.test', + openWebSocket: (...args: unknown[]) => mockOpenWebSocket(...args), +})); + +jest.mock('react-native-quick-crypto', () => ({ + randomUUID: () => '55555555-5555-4555-8555-555555555555', +})); + +import { EndpointBridgeClient, type EndpointProfile } from '../endpointBridge'; + +class MockSocket { + readyState = 0; + onopen: (() => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((event: { code: number }) => void) | null = null; + send = jest.fn(); + close = jest.fn(); +} + +const machineId = '11111111-1111-4111-8111-111111111111'; +const target = '22222222-2222-4222-8222-222222222222'; +const profile: EndpointProfile = { + device_name: '测试手机', + platform: 'ios', + os_version: '18.0', + arch: 'arm64', + client_version: 'test', +}; + +beforeAll(() => { + (global as any).WebSocket = { OPEN: 1 }; +}); + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +test('连接后握手并用全量快照替换端点目录', () => { + const socket = new MockSocket(); + mockOpenWebSocket.mockReturnValue(socket); + const onDirectory = jest.fn(); + const client = new EndpointBridgeClient({ + machineId, + profile, + onDirectory, + idFactory: () => '33333333-3333-4333-8333-333333333333', + }); + + client.connect(); + socket.readyState = 1; + socket.onopen?.(); + + expect(mockOpenWebSocket).toHaveBeenCalledWith('wss://api.example.test/api/v1/endpoints/connect'); + expect(JSON.parse(socket.send.mock.calls[0][0])).toEqual({ + type: 'hello', + protocol_versions: [1], + machine_id: machineId, + profile, + }); + + socket.onmessage?.({ data: JSON.stringify({ type: 'welcome', protocol_version: 1 }) }); + socket.onmessage?.({ + data: JSON.stringify({ + type: 'directory.snapshot', + endpoints: [{ machine_id: target, display_name: '电脑' }], + }), + }); + socket.onmessage?.({ + data: JSON.stringify({ + type: 'directory.snapshot', + endpoints: [{ machine_id: machineId, display_name: '手机' }], + }), + }); + + expect(onDirectory).toHaveBeenLastCalledWith([{ machine_id: machineId, display_name: '手机' }]); + expect(client.endpoints).toEqual([{ machine_id: machineId, display_name: '手机' }]); + client.disconnect(); +}); + +test('请求只接受目标端点的关联响应', async () => { + const socket = new MockSocket(); + mockOpenWebSocket.mockReturnValue(socket); + const ids = [ + '33333333-3333-4333-8333-333333333333', + '44444444-4444-4444-8444-444444444444', + ]; + const client = new EndpointBridgeClient({ + machineId, + profile, + idFactory: () => ids.shift()!, + }); + client.connect(); + socket.readyState = 1; + socket.onopen?.(); + socket.onmessage?.({ data: JSON.stringify({ type: 'welcome', protocol_version: 1 }) }); + + const pending = client.request(target, 'agent.continue', { task_id: 'task-1' }); + const outbound = JSON.parse(socket.send.mock.calls[1][0]); + socket.onmessage?.({ + data: JSON.stringify({ + type: 'response', + message_id: ids[0], + source: machineId, + target: machineId, + reply_to: outbound.message_id, + routed_at: Date.now(), + payload: { ignored: true }, + }), + }); + let settled = false; + void pending.finally(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + socket.onmessage?.({ + data: JSON.stringify({ + type: 'response', + message_id: ids[0], + source: target, + target: machineId, + reply_to: outbound.message_id, + routed_at: Date.now(), + payload: { result: 'ok' }, + }), + }); + await expect(pending).resolves.toEqual({ result: 'ok' }); + client.disconnect(); +}); + +test('超时和断线均以 outcome_unknown 结束 pending 且不自动重发', async () => { + const socket = new MockSocket(); + mockOpenWebSocket.mockReturnValue(socket); + const client = new EndpointBridgeClient({ + machineId, + profile, + idFactory: () => '33333333-3333-4333-8333-333333333333', + }); + client.connect(); + socket.readyState = 1; + socket.onopen?.(); + socket.onmessage?.({ data: JSON.stringify({ type: 'welcome', protocol_version: 1 }) }); + + const timedOut = client.request(target, 'agent.continue', {}, 1000); + jest.advanceTimersByTime(1000); + await expect(timedOut).rejects.toMatchObject({ code: 'outcome_unknown' }); + + const disconnected = client.request(target, 'agent.continue', {}); + socket.onclose?.({ code: 4001 }); + await expect(disconnected).rejects.toMatchObject({ code: 'outcome_unknown' }); + expect(socket.send).toHaveBeenCalledTimes(3); +}); diff --git a/mobile/src/api/__tests__/endpointIdentity.test.ts b/mobile/src/api/__tests__/endpointIdentity.test.ts new file mode 100644 index 000000000..5b03aa753 --- /dev/null +++ b/mobile/src/api/__tests__/endpointIdentity.test.ts @@ -0,0 +1,38 @@ +const mockGetItemAsync = jest.fn(); +const mockSetItemAsync = jest.fn(); + +jest.mock('expo-secure-store', () => ({ + getItemAsync: (...args: unknown[]) => mockGetItemAsync(...args), + setItemAsync: (...args: unknown[]) => mockSetItemAsync(...args), + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'device-only', +})); + +jest.mock('react-native-quick-crypto', () => ({ + randomUUID: () => '11111111-1111-4111-8111-111111111111', +})); + +beforeEach(() => { + jest.resetModules(); + mockGetItemAsync.mockReset(); + mockSetItemAsync.mockReset(); +}); + +test('首次安装生成 UUID 并保存到系统安全存储', async () => { + mockGetItemAsync.mockResolvedValue(null); + const { getOrCreateEndpointMachineId } = require('../endpointIdentity'); + + await expect(getOrCreateEndpointMachineId()).resolves.toBe('11111111-1111-4111-8111-111111111111'); + expect(mockSetItemAsync).toHaveBeenCalledWith( + 'monkeycode.endpoint.machine_id', + '11111111-1111-4111-8111-111111111111', + { keychainAccessible: 'device-only' }, + ); +}); + +test('已有合法机器标识直接复用', async () => { + mockGetItemAsync.mockResolvedValue('22222222-2222-4222-8222-222222222222'); + const { getOrCreateEndpointMachineId } = require('../endpointIdentity'); + + await expect(getOrCreateEndpointMachineId()).resolves.toBe('22222222-2222-4222-8222-222222222222'); + expect(mockSetItemAsync).not.toHaveBeenCalled(); +}); diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts index 5f8a6d865..8a6d97d82 100644 --- a/mobile/src/api/client.ts +++ b/mobile/src/api/client.ts @@ -123,7 +123,7 @@ function buildQuery(query?: Query): string { } interface RequestOpts { - method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; query?: Query; body?: unknown; } diff --git a/mobile/src/api/endpointBridge.ts b/mobile/src/api/endpointBridge.ts new file mode 100644 index 000000000..3a0a13416 --- /dev/null +++ b/mobile/src/api/endpointBridge.ts @@ -0,0 +1,513 @@ +import { getBaseUrl, openWebSocket, request } from './client'; +import { randomUUID } from 'react-native-quick-crypto'; + +export const ENDPOINT_PROTOCOL_VERSION = 1; +export const ENDPOINT_MAX_FILE_BYTES = 5 * 1024 * 1024; + +const DEFAULT_FRAME_BYTES = 256 * 1024; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000, 16_000, 30_000]; +const METHOD_PATTERN = /^[a-z][a-z0-9._-]{0,127}$/; +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type JsonObject = Record; +export type EndpointPlatform = 'macos' | 'windows' | 'linux' | 'ios' | 'android'; +export type EndpointConnectionState = 'idle' | 'connecting' | 'handshaking' | 'ready' | 'backoff' | 'stopped'; + +export interface EndpointProfile { + device_name: string; + platform: EndpointPlatform; + os_version: string; + arch: string; + client_version: string; +} + +export interface EndpointView { + machine_id: string; + device_name?: string; + alias?: string | null; + display_name: string; + platform?: EndpointPlatform; + os_version?: string; + arch?: string; + client_version?: string; + protocol_version?: number; + online?: boolean; + last_seen_at?: number | null; + status?: 'active' | 'revoked'; + created_at?: number; + updated_at?: number; +} + +export interface InboundAgentMessage { + type: 'event' | 'request'; + message_id: string; + source: string; + target: string; + method: string; + routed_at: number; + payload: JsonObject; +} + +interface InboundResponse { + type: 'response'; + message_id: string; + source: string; + target: string; + reply_to: string; + routed_at: number; + payload: JsonObject; +} + +interface ProtocolErrorMessage { + type: 'error'; + reply_to?: string | null; + error: { + code: string; + message: string; + retryable: boolean; + retry_after_ms?: number; + }; +} + +interface PendingRequest { + target: string; + timer: ReturnType; + resolve: (payload: JsonObject) => void; + reject: (error: EndpointBridgeError) => void; +} + +interface EndpointBridgeOptions { + machineId: string; + profile: EndpointProfile; + onDirectory?: (endpoints: EndpointView[]) => void; + onEvent?: (message: InboundAgentMessage) => void; + onRequest?: (message: InboundAgentMessage) => void; + onState?: (state: EndpointConnectionState) => void; + onProtocolError?: (error: ProtocolErrorMessage['error']) => void; + idFactory?: () => string; +} + +export interface EndpointAgentHandlers { + onEvent?: (message: InboundAgentMessage) => void; + onRequest?: (message: InboundAgentMessage) => void; +} + +export class EndpointBridgeError extends Error { + constructor( + public code: string, + message: string, + public retryable = false, + public retryAfterMs?: number, + ) { + super(message); + this.name = 'EndpointBridgeError'; + } +} + +export class EndpointBridgeClient { + private readonly machineId: string; + private readonly profile: EndpointProfile; + private readonly options: EndpointBridgeOptions; + private readonly idFactory: () => string; + private socket: WebSocket | null = null; + private pending = new Map(); + private reconnectTimer: ReturnType | null = null; + private reconnectAttempt = 0; + private connectionGeneration = 0; + private manuallyStopped = false; + private maxFrameBytes = DEFAULT_FRAME_BYTES; + private stableSince = 0; + private state: EndpointConnectionState = 'idle'; + private directory: EndpointView[] = []; + private handledResponseIds = new Set(); + private handledResponseOrder: string[] = []; + + constructor(options: EndpointBridgeOptions) { + if (!UUID_V4_PATTERN.test(options.machineId)) { + throw new EndpointBridgeError('invalid_machine_id', 'machine_id 必须是 UUIDv4'); + } + this.machineId = options.machineId; + this.profile = options.profile; + this.options = options; + this.idFactory = options.idFactory ?? createUUID; + } + + get connectionState(): EndpointConnectionState { + return this.state; + } + + get endpoints(): EndpointView[] { + return [...this.directory]; + } + + connect(): void { + this.manuallyStopped = false; + this.clearReconnectTimer(); + this.open(); + } + + disconnect(): void { + this.manuallyStopped = true; + this.clearReconnectTimer(); + this.finishPendingAsUnknown(); + const socket = this.socket; + this.socket = null; + this.connectionGeneration++; + if (socket) { + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(1000, 'manual disconnect'); + } + this.setState('stopped'); + } + + event(target: string, method: string, payload: JsonObject): string { + const messageId = this.nextMessageId(); + this.sendAgentMessage({ + type: 'event', + message_id: messageId, + target, + method, + payload, + }); + return messageId; + } + + request( + target: string, + method: string, + payload: JsonObject, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ): Promise { + const messageId = this.nextMessageId(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(messageId); + reject(new EndpointBridgeError('outcome_unknown', '请求结果未知')); + }, Math.max(1, timeoutMs)); + this.pending.set(messageId, { target, timer, resolve, reject }); + try { + this.sendAgentMessage({ + type: 'request', + message_id: messageId, + target, + method, + payload, + }); + } catch (error) { + clearTimeout(timer); + this.pending.delete(messageId); + reject(error); + } + }); + } + + respond(target: string, replyTo: string, payload: JsonObject): string { + if (!UUID_V4_PATTERN.test(replyTo)) { + throw new EndpointBridgeError('invalid_message', 'reply_to 必须是 UUIDv4'); + } + const messageId = this.nextMessageId(); + this.sendFrame({ + type: 'response', + message_id: messageId, + target, + reply_to: replyTo, + payload, + }); + return messageId; + } + + setAgentHandlers(handlers: EndpointAgentHandlers): void { + this.options.onEvent = handlers.onEvent; + this.options.onRequest = handlers.onRequest; + } + + private open(): void { + const generation = ++this.connectionGeneration; + this.cleanupSocket(); + this.setState('connecting'); + const socket = openWebSocket(endpointWebSocketURL()); + this.socket = socket; + socket.onopen = () => { + if (generation !== this.connectionGeneration) return; + this.setState('handshaking'); + socket.send(JSON.stringify({ + type: 'hello', + protocol_versions: [ENDPOINT_PROTOCOL_VERSION], + machine_id: this.machineId, + profile: this.profile, + })); + }; + socket.onmessage = (event) => { + if (generation !== this.connectionGeneration || typeof event.data !== 'string') return; + this.handleMessage(event.data); + }; + socket.onerror = () => { + if (generation !== this.connectionGeneration) return; + }; + socket.onclose = (event) => { + if (generation !== this.connectionGeneration) return; + this.socket = null; + this.finishPendingAsUnknown(); + if (this.manuallyStopped || !shouldReconnect(event.code)) { + this.setState('stopped'); + return; + } + if (this.stableSince > 0 && Date.now() - this.stableSince >= 60_000) { + this.reconnectAttempt = 0; + } + this.scheduleReconnect(); + }; + } + + private handleMessage(raw: string): void { + let message: unknown; + try { + message = JSON.parse(raw); + } catch { + return; + } + if (!isObject(message) || typeof message.type !== 'string') return; + switch (message.type) { + case 'welcome': { + if (this.state !== 'handshaking' || message.protocol_version !== ENDPOINT_PROTOCOL_VERSION) { + this.socket?.close(1002, 'unsupported protocol'); + return; + } + const limits = isObject(message.limits) ? message.limits : null; + if (limits && typeof limits.max_frame_bytes === 'number' && limits.max_frame_bytes > 0) { + this.maxFrameBytes = limits.max_frame_bytes; + } + this.stableSince = Date.now(); + this.setState('ready'); + return; + } + case 'directory.snapshot': { + if (this.state !== 'ready' || !Array.isArray(message.endpoints)) return; + this.directory = message.endpoints.filter(isEndpointView); + this.options.onDirectory?.([...this.directory]); + return; + } + case 'response': + this.handleResponse(message); + return; + case 'error': + this.handleProtocolError(message); + return; + case 'event': + case 'request': + this.handleInboundAgentMessage(message); + return; + default: + return; + } + } + + private handleResponse(message: JsonObject): void { + if (!isInboundResponse(message)) return; + if (this.handledResponseIds.has(message.message_id)) return; + const pending = this.pending.get(message.reply_to); + if (!pending || message.source !== pending.target || message.target !== this.machineId) return; + this.rememberResponse(message.message_id); + this.pending.delete(message.reply_to); + clearTimeout(pending.timer); + pending.resolve(message.payload); + } + + private handleProtocolError(message: JsonObject): void { + if (!isObject(message.error) || typeof message.error.code !== 'string') return; + const error = new EndpointBridgeError( + message.error.code, + typeof message.error.message === 'string' ? message.error.message : message.error.code, + message.error.retryable === true, + typeof message.error.retry_after_ms === 'number' ? message.error.retry_after_ms : undefined, + ); + if (typeof message.reply_to === 'string') { + const pending = this.pending.get(message.reply_to); + if (pending) { + this.pending.delete(message.reply_to); + clearTimeout(pending.timer); + pending.reject(error); + return; + } + } + this.options.onProtocolError?.({ + code: error.code, + message: error.message, + retryable: error.retryable, + retry_after_ms: error.retryAfterMs, + }); + } + + private handleInboundAgentMessage(message: JsonObject): void { + if (!isInboundAgentMessage(message) || message.target !== this.machineId) return; + if (message.type === 'event') { + this.options.onEvent?.(message); + } else { + this.options.onRequest?.(message); + } + } + + private sendAgentMessage(message: { + type: 'event' | 'request'; + message_id: string; + target: string; + method: string; + payload: JsonObject; + }): void { + if (!METHOD_PATTERN.test(message.method)) { + throw new EndpointBridgeError('invalid_method', 'Agent method 格式无效'); + } + this.sendFrame(message); + } + + private sendFrame(message: JsonObject): void { + if (this.state !== 'ready' || !this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new EndpointBridgeError('not_connected', '端点桥接连接尚未就绪'); + } + const raw = JSON.stringify(message); + if (utf8Bytes(raw) > this.maxFrameBytes) { + throw new EndpointBridgeError('payload_too_large', '消息超过单帧上限'); + } + this.socket.send(raw); + } + + private nextMessageId(): string { + const value = this.idFactory(); + if (!UUID_V4_PATTERN.test(value)) { + throw new EndpointBridgeError('invalid_message_id', '消息标识生成器必须返回 UUIDv4'); + } + return value; + } + + private rememberResponse(messageId: string): void { + this.handledResponseIds.add(messageId); + this.handledResponseOrder.push(messageId); + if (this.handledResponseOrder.length <= 2048) return; + const removed = this.handledResponseOrder.shift(); + if (removed) this.handledResponseIds.delete(removed); + } + + private scheduleReconnect(): void { + this.setState('backoff'); + const base = RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)]; + this.reconnectAttempt++; + const delay = Math.round(base * (0.8 + Math.random() * 0.4)); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (!this.manuallyStopped) this.open(); + }, delay); + } + + private finishPendingAsUnknown(): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new EndpointBridgeError('outcome_unknown', '请求结果未知')); + } + this.pending.clear(); + } + + private cleanupSocket(): void { + const socket = this.socket; + this.socket = null; + if (!socket) return; + socket.onopen = null; + socket.onmessage = null; + socket.onerror = null; + socket.onclose = null; + socket.close(1000, 'connection replaced'); + } + + private clearReconnectTimer(): void { + if (!this.reconnectTimer) return; + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + private setState(state: EndpointConnectionState): void { + if (this.state === state) return; + this.state = state; + this.options.onState?.(state); + } +} + +export async function listEndpoints(): Promise { + const response = await request('/api/v1/endpoints'); + return response.data ?? []; +} + +export async function getEndpoint(machineId: string): Promise { + const response = await request(`/api/v1/endpoints/${machineId}`); + if (!response.data) throw new EndpointBridgeError('invalid_response', '端点响应为空'); + return response.data; +} + +export async function updateEndpointAlias(machineId: string, alias: string | null): Promise { + const response = await request(`/api/v1/endpoints/${machineId}`, { + method: 'PATCH', + body: { alias }, + }); + if (!response.data) throw new EndpointBridgeError('invalid_response', '端点响应为空'); + return response.data; +} + +export async function revokeEndpoint(machineId: string): Promise { + await request(`/api/v1/endpoints/${machineId}/revoke`, { method: 'POST' }); +} + +export async function restoreEndpoint(machineId: string): Promise { + await request(`/api/v1/endpoints/${machineId}/restore`, { method: 'POST' }); +} + +function endpointWebSocketURL(): string { + const base = getBaseUrl().replace(/\/+$/, ''); + return `${base.replace(/^http/i, 'ws')}/api/v1/endpoints/connect`; +} + +function createUUID(): string { + return randomUUID(); +} + +function utf8Bytes(value: string): number { + if (typeof TextEncoder !== 'undefined') { + return new TextEncoder().encode(value).byteLength; + } + return encodeURIComponent(value).replace(/%[0-9A-F]{2}|./gi, 'x').length; +} + +function shouldReconnect(code: number): boolean { + return code === 0 || code === 1006 || code === 1011 || code === 1012 || code === 1013; +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isEndpointView(value: unknown): value is EndpointView { + return isObject(value) && typeof value.machine_id === 'string' && typeof value.display_name === 'string'; +} + +function isInboundResponse(value: JsonObject): value is JsonObject & InboundResponse { + return value.type === 'response' && + typeof value.message_id === 'string' && + UUID_V4_PATTERN.test(value.message_id) && + typeof value.source === 'string' && + typeof value.target === 'string' && + typeof value.reply_to === 'string' && + isObject(value.payload); +} + +function isInboundAgentMessage(value: JsonObject): value is JsonObject & InboundAgentMessage { + return (value.type === 'event' || value.type === 'request') && + typeof value.message_id === 'string' && + UUID_V4_PATTERN.test(value.message_id) && + typeof value.source === 'string' && + typeof value.target === 'string' && + typeof value.method === 'string' && + METHOD_PATTERN.test(value.method) && + typeof value.routed_at === 'number' && + isObject(value.payload); +} diff --git a/mobile/src/api/endpointIdentity.ts b/mobile/src/api/endpointIdentity.ts new file mode 100644 index 000000000..d3c410dba --- /dev/null +++ b/mobile/src/api/endpointIdentity.ts @@ -0,0 +1,22 @@ +import * as SecureStore from 'expo-secure-store'; +import { randomUUID } from 'react-native-quick-crypto'; + +const MACHINE_ID_KEY = 'monkeycode.endpoint.machine_id'; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +let current: Promise | null = null; + +export function getOrCreateEndpointMachineId(): Promise { + if (!current) current = loadEndpointMachineId(); + return current; +} + +async function loadEndpointMachineId(): Promise { + const stored = await SecureStore.getItemAsync(MACHINE_ID_KEY); + if (stored && UUID_PATTERN.test(stored)) return stored; + const machineId = randomUUID(); + await SecureStore.setItemAsync(MACHINE_ID_KEY, machineId, { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + }); + return machineId; +} From 49eb52d76cd38557859ab6c1182ed67b88d63583 Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Mon, 20 Jul 2026 15:21:00 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=A1=A5=E5=85=85=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/biz/endpoint/bridge.go | 95 ++++- backend/biz/endpoint/bridge_test.go | 83 +++++ backend/docs/swagger.json | 525 ++++++++++++++++++++++++++++ backend/domain/endpoint.go | 64 +++- 4 files changed, 743 insertions(+), 24 deletions(-) diff --git a/backend/biz/endpoint/bridge.go b/backend/biz/endpoint/bridge.go index b5ec4e387..18cc89c57 100644 --- a/backend/biz/endpoint/bridge.go +++ b/backend/biz/endpoint/bridge.go @@ -181,7 +181,7 @@ func (b *Bridge) Register(w *web.Web, auth *middleware.AuthMiddleware) { group.GET("/:machine_id", web.BindHandler(b.Get)) group.POST("/:machine_id/revoke", web.BindHandler(b.Revoke)) group.POST("/:machine_id/restore", web.BindHandler(b.Restore)) - w.Echo().PATCH("/api/v1/endpoints/:machine_id", b.updateEndpoint, endpointAuth) + w.Echo().PATCH("/api/v1/endpoints/:machine_id", b.Update, endpointAuth) w.Echo().Server.RegisterOnShutdown(b.Close) w.Echo().TLSServer.RegisterOnShutdown(b.Close) } @@ -225,6 +225,18 @@ func (b *Bridge) Close() { } } +// List 获取当前用户的全部端点 +// +// @Summary 获取端点列表 +// @Description 返回当前登录用户登记过的全部端点,包括 active 和 revoked 状态。online 表示端点当前是否持有有效在线租约;last_seen_at、created_at、updated_at 均为 Unix 毫秒时间戳。 +// @Tags 【用户】端点桥接 +// @Accept json +// @Produce json +// @Security MonkeyCodeAIAuth +// @Success 200 {object} web.Resp{data=[]domain.EndpointView} "成功" +// @Failure 401 {object} web.Resp "登录会话无效" +// @Failure 500 {object} web.Resp "服务器内部错误" +// @Router /api/v1/endpoints [get] func (b *Bridge) List(c *web.Context) error { user := middleware.GetUser(c) items, err := b.listEndpoints(c.Request().Context(), user.ID, true) @@ -234,6 +246,21 @@ func (b *Bridge) List(c *web.Context) error { return c.Success(items) } +// Get 获取指定端点 +// +// @Summary 获取端点详情 +// @Description 按安装级 machine_id 查询当前登录用户自己的端点。其他用户的端点与不存在的端点统一返回资源不存在,避免跨用户枚举。 +// @Tags 【用户】端点桥接 +// @Accept json +// @Produce json +// @Security MonkeyCodeAIAuth +// @Param machine_id path string true "安装实例首次启动生成的 UUIDv4 机器标识" Format(uuid) +// @Success 200 {object} web.Resp{data=domain.EndpointView} "成功" +// @Failure 400 {object} web.Resp "machine_id 格式无效" +// @Failure 401 {object} web.Resp "登录会话无效" +// @Failure 404 {object} web.Resp "端点不存在" +// @Failure 500 {object} web.Resp "服务器内部错误" +// @Router /api/v1/endpoints/{machine_id} [get] func (b *Bridge) Get(c *web.Context, req domain.EndpointPathReq) error { user := middleware.GetUser(c) item, err := b.db.Endpoint.Query(). @@ -249,14 +276,28 @@ func (b *Bridge) Get(c *web.Context, req domain.EndpointPathReq) error { return c.Success(view) } -func (b *Bridge) updateEndpoint(c echo.Context) error { +// Update 修改端点别名 +// +// @Summary 修改端点别名 +// @Description 修改当前登录用户指定端点的展示别名。alias 为 null、空字符串或仅含空白字符时清除别名;别名最长 128 个 Unicode 字符。后续 hello 上报只更新系统设备资料,不会覆盖别名。 +// @Tags 【用户】端点桥接 +// @Accept json +// @Produce json +// @Security MonkeyCodeAIAuth +// @Param machine_id path string true "安装实例首次启动生成的 UUIDv4 机器标识" Format(uuid) +// @Param req body domain.UpdateEndpointReq true "端点别名;null 或空字符串表示清除" +// @Success 200 {object} web.Resp{data=domain.EndpointView} "成功" +// @Failure 400 {object} web.Resp "请求参数无效或别名过长" +// @Failure 401 {object} web.Resp "登录会话无效" +// @Failure 404 {object} web.Resp "端点不存在" +// @Failure 500 {object} web.Resp "服务器内部错误" +// @Router /api/v1/endpoints/{machine_id} [patch] +func (b *Bridge) Update(c echo.Context) error { machineID, err := uuid.Parse(c.Param("machine_id")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]any{"code": http.StatusBadRequest, "message": "machine_id 无效"}) } - var body struct { - Alias *string `json:"alias"` - } + var body domain.UpdateEndpointReq if err := c.Bind(&body); err != nil { return c.JSON(http.StatusBadRequest, map[string]any{"code": http.StatusBadRequest, "message": "请求体无效"}) } @@ -292,6 +333,21 @@ func (b *Bridge) updateEndpoint(c echo.Context) error { }) } +// Revoke 撤销指定端点 +// +// @Summary 撤销端点 +// @Description 将当前登录用户的端点标记为 revoked,并立即关闭该端点的在线连接。被撤销端点不会出现在 WebSocket 目录中,也不能通过 hello 自动重新登记;撤销不等同于撤销该设备上的登录会话。 +// @Tags 【用户】端点桥接 +// @Accept json +// @Produce json +// @Security MonkeyCodeAIAuth +// @Param machine_id path string true "安装实例首次启动生成的 UUIDv4 机器标识" Format(uuid) +// @Success 200 {object} web.Resp{data=domain.EndpointStatusResp} "成功,status 为 revoked" +// @Failure 400 {object} web.Resp "machine_id 格式无效" +// @Failure 401 {object} web.Resp "登录会话无效" +// @Failure 404 {object} web.Resp "端点不存在" +// @Failure 500 {object} web.Resp "服务器内部错误" +// @Router /api/v1/endpoints/{machine_id}/revoke [post] func (b *Bridge) Revoke(c *web.Context, req domain.EndpointPathReq) error { user := middleware.GetUser(c) affected, err := b.db.Endpoint.Update(). @@ -315,6 +371,21 @@ func (b *Bridge) Revoke(c *web.Context, req domain.EndpointPathReq) error { return c.Success(map[string]any{"machine_id": req.MachineID, "status": domain.EndpointStatusRevoked}) } +// Restore 恢复指定端点 +// +// @Summary 恢复端点 +// @Description 将当前登录用户已撤销的端点恢复为 active。接口幂等,端点已启用时仍返回成功;恢复后的端点可以重新建立桥接连接。当未撤销端点已达到部署配置上限时返回业务错误码 10009。 +// @Tags 【用户】端点桥接 +// @Accept json +// @Produce json +// @Security MonkeyCodeAIAuth +// @Param machine_id path string true "安装实例首次启动生成的 UUIDv4 机器标识" Format(uuid) +// @Success 200 {object} web.Resp{data=domain.EndpointStatusResp} "成功,status 为 active" +// @Failure 400 {object} web.Resp "machine_id 格式无效" +// @Failure 401 {object} web.Resp "登录会话无效" +// @Failure 404 {object} web.Resp "端点不存在" +// @Failure 500 {object} web.Resp "服务器内部错误" +// @Router /api/v1/endpoints/{machine_id}/restore [post] func (b *Bridge) Restore(c *web.Context, req domain.EndpointPathReq) error { ctx := c.Request().Context() user := middleware.GetUser(c) @@ -376,6 +447,20 @@ func (b *Bridge) Restore(c *web.Context, req domain.EndpointPathReq) error { return c.Success(map[string]any{"machine_id": req.MachineID, "status": domain.EndpointStatusActive}) } +// Connect 建立端点桥接 WebSocket +// +// @Summary 建立端点桥接连接 +// @Description 该路由用于 WebSocket Upgrade,不是普通 REST 查询。原生桌面端和移动端复用当前登录 Cookie 连接;生产环境必须使用 WSS,非空 Origin 必须匹配服务端白名单。 +// @Description Upgrade 成功后,客户端须在 5 秒内发送 hello 文本帧,包含支持的协议主版本、安装级 UUIDv4 machine_id 和设备资料。服务端返回 welcome,并立即发送同一用户全部未撤销端点的 directory.snapshot 全量快照。 +// @Description 业务帧仅支持 UTF-8 JSON 文本格式的 event、request、response;单帧最大 256 KiB,不支持二进制帧和压缩。完整消息结构、关闭码和重连规则见 docs/design/2026-07-17-client-bridge-client-protocol.md。 +// @Tags 【用户】端点桥接 +// @Security MonkeyCodeAIAuth +// @Param Upgrade header string true "固定为 websocket" +// @Param Connection header string true "包含 Upgrade" +// @Success 101 {string} string "切换到 WebSocket 协议;后续通过文本帧交换 hello、welcome、directory.snapshot 和业务消息" +// @Failure 401 {string} string "登录会话无效" +// @Failure 403 {string} string "必须使用 WSS 或 Origin 不受信任" +// @Router /api/v1/endpoints/connect [get] func (b *Bridge) Connect(c *web.Context) error { if err := b.validateUpgrade(c.Request()); err != nil { return c.String(http.StatusForbidden, err.Error()) diff --git a/backend/biz/endpoint/bridge_test.go b/backend/biz/endpoint/bridge_test.go index 4f458431c..7d9bca6d1 100644 --- a/backend/biz/endpoint/bridge_test.go +++ b/backend/biz/endpoint/bridge_test.go @@ -10,6 +10,7 @@ import ( "net" "net/http" "net/http/httptest" + "os" "strings" "testing" "time" @@ -357,6 +358,88 @@ func TestDevelopmentPlainWebSocketOnlyAllowsLoopback(t *testing.T) { } } +func TestEndpointSwaggerCoverage(t *testing.T) { + raw, err := os.ReadFile("../../docs/swagger.json") + if err != nil { + t.Fatal(err) + } + var spec struct { + Paths map[string]map[string]struct { + Summary string `json:"summary"` + Description string `json:"description"` + Security []map[string]json.RawMessage `json:"security"` + Responses map[string]json.RawMessage `json:"responses"` + } `json:"paths"` + Definitions map[string]json.RawMessage `json:"definitions"` + } + if err := json.Unmarshal(raw, &spec); err != nil { + t.Fatal(err) + } + expected := []struct { + path string + method string + successCode string + }{ + {"/api/v1/endpoints", "get", "200"}, + {"/api/v1/endpoints/{machine_id}", "get", "200"}, + {"/api/v1/endpoints/{machine_id}", "patch", "200"}, + {"/api/v1/endpoints/{machine_id}/revoke", "post", "200"}, + {"/api/v1/endpoints/{machine_id}/restore", "post", "200"}, + {"/api/v1/endpoints/connect", "get", "101"}, + } + for _, item := range expected { + operation, ok := spec.Paths[item.path][item.method] + if !ok { + t.Errorf("Swagger 缺少 %s %s", item.method, item.path) + continue + } + if operation.Summary == "" || operation.Description == "" { + t.Errorf("Swagger 接口说明不完整: %s %s", item.method, item.path) + } + hasCookieAuth := false + for _, security := range operation.Security { + if _, ok := security["MonkeyCodeAIAuth"]; ok { + hasCookieAuth = true + break + } + } + if !hasCookieAuth { + t.Errorf("Swagger 缺少 Cookie 鉴权声明: %s %s", item.method, item.path) + } + if _, ok := operation.Responses[item.successCode]; !ok { + t.Errorf("Swagger 缺少成功响应 %s: %s %s", item.successCode, item.method, item.path) + } + } + for _, name := range []string{ + "domain.EndpointView", + "domain.UpdateEndpointReq", + "domain.EndpointStatusResp", + } { + rawDefinition, ok := spec.Definitions[name] + if !ok { + t.Errorf("Swagger 缺少模型定义 %s", name) + continue + } + var definition struct { + Properties map[string]struct { + Description string `json:"description"` + } `json:"properties"` + } + if err := json.Unmarshal(rawDefinition, &definition); err != nil { + t.Errorf("解析 Swagger 模型定义失败 %s: %v", name, err) + continue + } + if len(definition.Properties) == 0 { + t.Errorf("Swagger 模型没有字段定义 %s", name) + } + for field, property := range definition.Properties { + if property.Description == "" { + t.Errorf("Swagger 模型字段缺少说明 %s.%s", name, field) + } + } + } +} + func newBridgeTestServer(t *testing.T, dbClient *db.Client, rdb *redis.Client, redisAddr, instanceID string) *httptest.Server { t.Helper() return newBridgeTestServerWithConfig(t, dbClient, rdb, redisAddr, instanceID, nil) diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index c39d7000e..f85476b42 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -4,6 +4,414 @@ "contact": {} }, "paths": { + "/api/v1/endpoints": { + "get": { + "security": [ + { + "MonkeyCodeAIAuth": [] + } + ], + "description": "返回当前登录用户登记过的全部端点,包括 active 和 revoked 状态。online 表示端点当前是否持有有效在线租约;last_seen_at、created_at、updated_at 均为 Unix 毫秒时间戳。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "【用户】端点桥接" + ], + "summary": "获取端点列表", + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.Resp" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.EndpointView" + } + } + } + } + ] + } + }, + "401": { + "description": "登录会话无效", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "500": { + "description": "服务器内部错误", + "schema": { + "$ref": "#/definitions/web.Resp" + } + } + } + } + }, + "/api/v1/endpoints/connect": { + "get": { + "security": [ + { + "MonkeyCodeAIAuth": [] + } + ], + "description": "该路由用于 WebSocket Upgrade,不是普通 REST 查询。原生桌面端和移动端复用当前登录 Cookie 连接;生产环境必须使用 WSS,非空 Origin 必须匹配服务端白名单。\nUpgrade 成功后,客户端须在 5 秒内发送 hello 文本帧,包含支持的协议主版本、安装级 UUIDv4 machine_id 和设备资料。服务端返回 welcome,并立即发送同一用户全部未撤销端点的 directory.snapshot 全量快照。\n业务帧仅支持 UTF-8 JSON 文本格式的 event、request、response;单帧最大 256 KiB,不支持二进制帧和压缩。完整消息结构、关闭码和重连规则见 docs/design/2026-07-17-client-bridge-client-protocol.md。", + "tags": [ + "【用户】端点桥接" + ], + "summary": "建立端点桥接连接", + "parameters": [ + { + "type": "string", + "description": "固定为 websocket", + "name": "Upgrade", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "包含 Upgrade", + "name": "Connection", + "in": "header", + "required": true + } + ], + "responses": { + "101": { + "description": "切换到 WebSocket 协议;后续通过文本帧交换 hello、welcome、directory.snapshot 和业务消息", + "schema": { + "type": "string" + } + }, + "401": { + "description": "登录会话无效", + "schema": { + "type": "string" + } + }, + "403": { + "description": "必须使用 WSS 或 Origin 不受信任", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/endpoints/{machine_id}": { + "get": { + "security": [ + { + "MonkeyCodeAIAuth": [] + } + ], + "description": "按安装级 machine_id 查询当前登录用户自己的端点。其他用户的端点与不存在的端点统一返回资源不存在,避免跨用户枚举。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "【用户】端点桥接" + ], + "summary": "获取端点详情", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "安装实例首次启动生成的 UUIDv4 机器标识", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.Resp" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.EndpointView" + } + } + } + ] + } + }, + "400": { + "description": "machine_id 格式无效", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "401": { + "description": "登录会话无效", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "404": { + "description": "端点不存在", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "500": { + "description": "服务器内部错误", + "schema": { + "$ref": "#/definitions/web.Resp" + } + } + } + }, + "patch": { + "security": [ + { + "MonkeyCodeAIAuth": [] + } + ], + "description": "修改当前登录用户指定端点的展示别名。alias 为 null、空字符串或仅含空白字符时清除别名;别名最长 128 个 Unicode 字符。后续 hello 上报只更新系统设备资料,不会覆盖别名。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "【用户】端点桥接" + ], + "summary": "修改端点别名", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "安装实例首次启动生成的 UUIDv4 机器标识", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "description": "端点别名;null 或空字符串表示清除", + "name": "req", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/domain.UpdateEndpointReq" + } + } + ], + "responses": { + "200": { + "description": "成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.Resp" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.EndpointView" + } + } + } + ] + } + }, + "400": { + "description": "请求参数无效或别名过长", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "401": { + "description": "登录会话无效", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "404": { + "description": "端点不存在", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "500": { + "description": "服务器内部错误", + "schema": { + "$ref": "#/definitions/web.Resp" + } + } + } + } + }, + "/api/v1/endpoints/{machine_id}/restore": { + "post": { + "security": [ + { + "MonkeyCodeAIAuth": [] + } + ], + "description": "将当前登录用户已撤销的端点恢复为 active。接口幂等,端点已启用时仍返回成功;恢复后的端点可以重新建立桥接连接。当未撤销端点已达到部署配置上限时返回业务错误码 10009。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "【用户】端点桥接" + ], + "summary": "恢复端点", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "安装实例首次启动生成的 UUIDv4 机器标识", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "成功,status 为 active", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.Resp" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.EndpointStatusResp" + } + } + } + ] + } + }, + "400": { + "description": "machine_id 格式无效", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "401": { + "description": "登录会话无效", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "404": { + "description": "端点不存在", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "500": { + "description": "服务器内部错误", + "schema": { + "$ref": "#/definitions/web.Resp" + } + } + } + } + }, + "/api/v1/endpoints/{machine_id}/revoke": { + "post": { + "security": [ + { + "MonkeyCodeAIAuth": [] + } + ], + "description": "将当前登录用户的端点标记为 revoked,并立即关闭该端点的在线连接。被撤销端点不会出现在 WebSocket 目录中,也不能通过 hello 自动重新登记;撤销不等同于撤销该设备上的登录会话。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "【用户】端点桥接" + ], + "summary": "撤销端点", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "安装实例首次启动生成的 UUIDv4 机器标识", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "成功,status 为 revoked", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/web.Resp" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/domain.EndpointStatusResp" + } + } + } + ] + } + }, + "400": { + "description": "machine_id 格式无效", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "401": { + "description": "登录会话无效", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "404": { + "description": "端点不存在", + "schema": { + "$ref": "#/definitions/web.Resp" + } + }, + "500": { + "description": "服务器内部错误", + "schema": { + "$ref": "#/definitions/web.Resp" + } + } + } + } + }, "/api/v1/plugins": { "get": { "security": [ @@ -10926,6 +11334,113 @@ } } }, + "domain.EndpointStatusResp": { + "type": "object", + "properties": { + "machine_id": { + "description": "MachineID 是本次操作对应的端点机器标识。", + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "status": { + "description": "Status 是操作完成后的端点管理状态。", + "type": "string", + "enum": [ + "active", + "revoked" + ], + "example": "active" + } + } + }, + "domain.EndpointView": { + "type": "object", + "properties": { + "alias": { + "description": "Alias 是用户设置的端点别名,未设置时为空。", + "type": "string", + "example": "办公电脑" + }, + "arch": { + "description": "Arch 是客户端上报的处理器架构。", + "type": "string", + "example": "arm64" + }, + "client_version": { + "description": "ClientVersion 是 MonkeyCode 客户端版本。", + "type": "string", + "example": "1.0.0" + }, + "created_at": { + "description": "CreatedAt 是端点首次登记时间,单位为 Unix 毫秒。", + "type": "integer", + "example": 1752739200000 + }, + "device_name": { + "description": "DeviceName 是客户端上报的系统设备名。", + "type": "string", + "example": "MacBook Pro" + }, + "display_name": { + "description": "DisplayName 是端点展示名,优先使用 alias,否则使用 device_name。", + "type": "string", + "example": "办公电脑" + }, + "last_seen_at": { + "description": "LastSeenAt 是端点最后在线时间,单位为 Unix 毫秒。", + "type": "integer", + "example": 1752739200000 + }, + "machine_id": { + "description": "MachineID 是客户端首次安装生成的 UUIDv4,仅用于同一用户内稳定寻址。", + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "online": { + "description": "Online 表示端点当前是否持有有效的在线租约。", + "type": "boolean", + "example": true + }, + "os_version": { + "description": "OSVersion 是客户端上报的操作系统版本。", + "type": "string", + "example": "15.5" + }, + "platform": { + "description": "Platform 是客户端操作系统平台。", + "type": "string", + "enum": [ + "macos", + "windows", + "linux", + "ios", + "android" + ], + "example": "macos" + }, + "protocol_version": { + "description": "ProtocolVersion 是端点最近一次连接协商成功的桥接协议主版本。", + "type": "integer", + "example": 1 + }, + "status": { + "description": "Status 是端点管理状态:active 表示可连接,revoked 表示已撤销。", + "type": "string", + "enum": [ + "active", + "revoked" + ], + "example": "active" + }, + "updated_at": { + "description": "UpdatedAt 是端点资料最后更新时间,单位为 Unix 毫秒。", + "type": "integer", + "example": 1752739200000 + } + } + }, "domain.FileChangeReq": { "type": "object", "required": [ @@ -13740,6 +14255,16 @@ } } }, + "domain.UpdateEndpointReq": { + "type": "object", + "properties": { + "alias": { + "description": "Alias 是新的端点别名;null、空字符串或仅含空白字符时清除别名,最长 128 个 Unicode 字符。", + "type": "string", + "example": "办公电脑" + } + } + }, "domain.UpdateGitBotReq": { "type": "object", "required": [ diff --git a/backend/domain/endpoint.go b/backend/domain/endpoint.go index cc262bf04..fbda346a0 100644 --- a/backend/domain/endpoint.go +++ b/backend/domain/endpoint.go @@ -12,28 +12,42 @@ const ( ) type EndpointProfile struct { - DeviceName string `json:"device_name"` - Platform string `json:"platform"` - OSVersion string `json:"os_version"` - Arch string `json:"arch"` - ClientVersion string `json:"client_version"` + DeviceName string `json:"device_name" example:"办公电脑"` + Platform string `json:"platform" enums:"macos,windows,linux,ios,android" example:"macos"` + OSVersion string `json:"os_version" example:"15.5"` + Arch string `json:"arch" example:"arm64"` + ClientVersion string `json:"client_version" example:"1.0.0"` } type EndpointView struct { - MachineID uuid.UUID `json:"machine_id"` - DeviceName string `json:"device_name"` - Alias *string `json:"alias"` - DisplayName string `json:"display_name"` - Platform string `json:"platform"` - OSVersion string `json:"os_version"` - Arch string `json:"arch"` - ClientVersion string `json:"client_version"` - ProtocolVersion int `json:"protocol_version"` - Status string `json:"status,omitempty"` - Online bool `json:"online"` - LastSeenAt *int64 `json:"last_seen_at"` - CreatedAt *int64 `json:"created_at,omitempty"` - UpdatedAt *int64 `json:"updated_at,omitempty"` + // MachineID 是客户端首次安装生成的 UUIDv4,仅用于同一用户内稳定寻址。 + MachineID uuid.UUID `json:"machine_id" format:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` + // DeviceName 是客户端上报的系统设备名。 + DeviceName string `json:"device_name" example:"MacBook Pro"` + // Alias 是用户设置的端点别名,未设置时为空。 + Alias *string `json:"alias" example:"办公电脑"` + // DisplayName 是端点展示名,优先使用 alias,否则使用 device_name。 + DisplayName string `json:"display_name" example:"办公电脑"` + // Platform 是客户端操作系统平台。 + Platform string `json:"platform" enums:"macos,windows,linux,ios,android" example:"macos"` + // OSVersion 是客户端上报的操作系统版本。 + OSVersion string `json:"os_version" example:"15.5"` + // Arch 是客户端上报的处理器架构。 + Arch string `json:"arch" example:"arm64"` + // ClientVersion 是 MonkeyCode 客户端版本。 + ClientVersion string `json:"client_version" example:"1.0.0"` + // ProtocolVersion 是端点最近一次连接协商成功的桥接协议主版本。 + ProtocolVersion int `json:"protocol_version" example:"1"` + // Status 是端点管理状态:active 表示可连接,revoked 表示已撤销。 + Status string `json:"status,omitempty" enums:"active,revoked" example:"active"` + // Online 表示端点当前是否持有有效的在线租约。 + Online bool `json:"online" example:"true"` + // LastSeenAt 是端点最后在线时间,单位为 Unix 毫秒。 + LastSeenAt *int64 `json:"last_seen_at" example:"1752739200000"` + // CreatedAt 是端点首次登记时间,单位为 Unix 毫秒。 + CreatedAt *int64 `json:"created_at,omitempty" example:"1752739200000"` + // UpdatedAt 是端点资料最后更新时间,单位为 Unix 毫秒。 + UpdatedAt *int64 `json:"updated_at,omitempty" example:"1752739200000"` } type EndpointHello struct { @@ -57,3 +71,15 @@ type EndpointEnvelope struct { type EndpointPathReq struct { MachineID uuid.UUID `param:"machine_id" validate:"required"` } + +type UpdateEndpointReq struct { + // Alias 是新的端点别名;null、空字符串或仅含空白字符时清除别名,最长 128 个 Unicode 字符。 + Alias *string `json:"alias" example:"办公电脑"` +} + +type EndpointStatusResp struct { + // MachineID 是本次操作对应的端点机器标识。 + MachineID uuid.UUID `json:"machine_id" format:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` + // Status 是操作完成后的端点管理状态。 + Status string `json:"status" enums:"active,revoked" example:"active"` +}