From 81c9c75424390b442d3d6c042add4852d9a82d8c Mon Sep 17 00:00:00 2001 From: LPF Date: Tue, 17 Mar 2026 17:06:59 +0800 Subject: [PATCH] Remove node runtime and config surface --- README_EN.md | 47 - cmd/artifacts/node/camera-test.jpg | 1 + cmd/artifacts/node/camera-test.mp4 | 1 + cmd/artifacts/node/screen-test.mp4 | 1 + cmd/artifacts/node/screen-test.png | 1 + cmd/cmd_gateway.go | 62 +- cmd/cmd_node.go | 1540 ----------------------- cmd/cmd_node_test.go | 497 -------- cmd/cmd_status.go | 33 - cmd/cmd_status_test.go | 12 - cmd/main.go | 2 - config.example.json | 32 +- pkg/agent/loop.go | 176 +-- pkg/agent/loop_nodes_p2p_test.go | 32 - pkg/agent/subagent_node_test.go | 61 - pkg/api/server.go | 1462 +-------------------- pkg/api/server_test.go | 1129 +---------------- pkg/config/config.go | 71 +- pkg/config/validate.go | 50 +- pkg/config/validate_test.go | 139 -- pkg/nodes/manager.go | 693 ---------- pkg/nodes/manager_test.go | 212 ---- pkg/nodes/reload_unix.go | 12 - pkg/nodes/reload_windows.go | 8 - pkg/nodes/transport.go | 356 ------ pkg/nodes/transport_test.go | 266 ---- pkg/nodes/types.go | 98 -- pkg/nodes/webrtc.go | 439 ------- pkg/tools/highlevel_arg_parsing_test.go | 74 -- pkg/tools/nodes_tool.go | 255 ---- pkg/tools/subagent.go | 4 - pkg/tools/subagent_profile.go | 146 --- 32 files changed, 65 insertions(+), 7847 deletions(-) create mode 100644 cmd/artifacts/node/camera-test.jpg create mode 100644 cmd/artifacts/node/camera-test.mp4 create mode 100644 cmd/artifacts/node/screen-test.mp4 create mode 100644 cmd/artifacts/node/screen-test.png delete mode 100644 cmd/cmd_node.go delete mode 100644 cmd/cmd_node_test.go delete mode 100644 pkg/agent/loop_nodes_p2p_test.go delete mode 100644 pkg/agent/subagent_node_test.go delete mode 100644 pkg/nodes/manager.go delete mode 100644 pkg/nodes/manager_test.go delete mode 100644 pkg/nodes/reload_unix.go delete mode 100644 pkg/nodes/reload_windows.go delete mode 100644 pkg/nodes/transport.go delete mode 100644 pkg/nodes/transport_test.go delete mode 100644 pkg/nodes/types.go delete mode 100644 pkg/nodes/webrtc.go delete mode 100644 pkg/tools/highlevel_arg_parsing_test.go delete mode 100644 pkg/tools/nodes_tool.go diff --git a/README_EN.md b/README_EN.md index ddc57f4..0202443 100644 --- a/README_EN.md +++ b/README_EN.md @@ -230,53 +230,6 @@ Notes: See [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json) for a full example. -## Node P2P - -The remote node data plane supports: - -- `websocket_tunnel` -- `webrtc` - -It remains disabled by default. Node P2P is only enabled when `gateway.nodes.p2p.enabled=true` is set explicitly. In practice, start with `websocket_tunnel`, then switch to `webrtc` after validating connectivity. - -For `webrtc`, these two fields matter: - -- `stun_servers` - - legacy-compatible STUN list -- `ice_servers` - - the preferred structured format - - may include `stun:`, `turn:`, and `turns:` URLs - - `turn:` / `turns:` entries require both `username` and `credential` - -Example: - -```json -{ - "gateway": { - "nodes": { - "p2p": { - "enabled": true, - "transport": "webrtc", - "stun_servers": ["stun:stun.l.google.com:19302"], - "ice_servers": [ - { - "urls": ["turn:turn.example.com:3478"], - "username": "demo", - "credential": "secret" - } - ] - } - } - } -} -``` - -Notes: - -- when `webrtc` session setup fails, dispatch still falls back to the existing relay / tunnel path -- Dashboard, `status`, and `/api/nodes` expose the current Node P2P runtime summary -- a reusable public-network validation flow is documented in [docs/node-p2p-e2e.md](/Users/lpf/Desktop/project/clawgo/docs/node-p2p-e2e.md) - ## MCP Server Support ClawGo now supports `stdio`, `http`, `streamable_http`, and `sse` MCP servers through `tools.mcp`. diff --git a/cmd/artifacts/node/camera-test.jpg b/cmd/artifacts/node/camera-test.jpg new file mode 100644 index 0000000..93ebd05 --- /dev/null +++ b/cmd/artifacts/node/camera-test.jpg @@ -0,0 +1 @@ +camera-bytes \ No newline at end of file diff --git a/cmd/artifacts/node/camera-test.mp4 b/cmd/artifacts/node/camera-test.mp4 new file mode 100644 index 0000000..bcfd37a --- /dev/null +++ b/cmd/artifacts/node/camera-test.mp4 @@ -0,0 +1 @@ +video-bytes \ No newline at end of file diff --git a/cmd/artifacts/node/screen-test.mp4 b/cmd/artifacts/node/screen-test.mp4 new file mode 100644 index 0000000..4d72d69 --- /dev/null +++ b/cmd/artifacts/node/screen-test.mp4 @@ -0,0 +1 @@ +screen-video \ No newline at end of file diff --git a/cmd/artifacts/node/screen-test.png b/cmd/artifacts/node/screen-test.png new file mode 100644 index 0000000..2371c64 --- /dev/null +++ b/cmd/artifacts/node/screen-test.png @@ -0,0 +1 @@ +‰PNG \ No newline at end of file diff --git a/cmd/cmd_gateway.go b/cmd/cmd_gateway.go index 946ede2..3018b54 100644 --- a/cmd/cmd_gateway.go +++ b/cmd/cmd_gateway.go @@ -24,12 +24,10 @@ import ( "github.com/YspCoder/clawgo/pkg/cron" "github.com/YspCoder/clawgo/pkg/heartbeat" "github.com/YspCoder/clawgo/pkg/logger" - "github.com/YspCoder/clawgo/pkg/nodes" "github.com/YspCoder/clawgo/pkg/providers" "github.com/YspCoder/clawgo/pkg/runtimecfg" "github.com/YspCoder/clawgo/pkg/sentinel" "github.com/YspCoder/clawgo/pkg/wsrelay" - "github.com/pion/webrtc/v4" ) func gatewayCmd() { @@ -125,57 +123,7 @@ func gatewayCmd() { fmt.Println("Sentinel service started") } - registryServer := api.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, cfg.Gateway.Token, nodes.DefaultManager()) - configureGatewayNodeP2P := func(loop *agent.AgentLoop, server *api.Server, runtimeCfg *config.Config) { - if loop == nil || server == nil || runtimeCfg == nil { - return - } - buildICEServers := func() []webrtc.ICEServer { - out := make([]webrtc.ICEServer, 0, len(runtimeCfg.Gateway.Nodes.P2P.ICEServers)) - for _, serverCfg := range runtimeCfg.Gateway.Nodes.P2P.ICEServers { - urls := make([]string, 0, len(serverCfg.URLs)) - for _, raw := range serverCfg.URLs { - if v := strings.TrimSpace(raw); v != "" { - urls = append(urls, v) - } - } - if len(urls) == 0 { - continue - } - out = append(out, webrtc.ICEServer{ - URLs: urls, - Username: strings.TrimSpace(serverCfg.Username), - Credential: serverCfg.Credential, - }) - } - return out - } - server.SetNodeP2PStatusHandler(func() map[string]interface{} { - return map[string]interface{}{ - "enabled": runtimeCfg.Gateway.Nodes.P2P.Enabled, - "transport": strings.TrimSpace(runtimeCfg.Gateway.Nodes.P2P.Transport), - "configured_stun": append([]string(nil), runtimeCfg.Gateway.Nodes.P2P.STUNServers...), - "configured_ice": len(runtimeCfg.Gateway.Nodes.P2P.ICEServers), - } - }) - switch { - case runtimeCfg.Gateway.Nodes.P2P.Enabled && strings.EqualFold(strings.TrimSpace(runtimeCfg.Gateway.Nodes.P2P.Transport), "webrtc"): - webrtcTransport := nodes.NewWebRTCTransport(runtimeCfg.Gateway.Nodes.P2P.STUNServers, buildICEServers()...) - loop.SetNodeP2PTransport(webrtcTransport) - server.SetNodeWebRTCTransport(webrtcTransport) - server.SetNodeP2PStatusHandler(func() map[string]interface{} { - snapshot := webrtcTransport.Snapshot() - snapshot["enabled"] = true - snapshot["transport"] = "webrtc" - snapshot["configured_stun"] = append([]string(nil), runtimeCfg.Gateway.Nodes.P2P.STUNServers...) - snapshot["configured_ice"] = len(runtimeCfg.Gateway.Nodes.P2P.ICEServers) - return snapshot - }) - default: - server.SetNodeWebRTCTransport(nil) - } - } - configureGatewayNodeP2P(agentLoop, registryServer, cfg) + registryServer := api.NewServer(cfg.Gateway.Host, cfg.Gateway.Port, cfg.Gateway.Token) registryServer.SetGatewayVersion(version) registryServer.SetConfigPath(getConfigPath()) registryServer.SetToken(cfg.Gateway.Token) @@ -221,9 +169,6 @@ func gatewayCmd() { } return out }) - registryServer.SetNodeDispatchHandler(func(cctx context.Context, req nodes.Request, mode string) (nodes.Response, error) { - return loop.DispatchNodeRequest(cctx, req, mode) - }) registryServer.SetToolsCatalogHandler(func() interface{} { return loop.GetToolCatalog() }) @@ -429,8 +374,7 @@ func gatewayCmd() { runtimeSame := reflect.DeepEqual(cfg.Agents, newCfg.Agents) && reflect.DeepEqual(cfg.Models, newCfg.Models) && reflect.DeepEqual(cfg.Tools, newCfg.Tools) && - reflect.DeepEqual(cfg.Channels, newCfg.Channels) && - reflect.DeepEqual(cfg.Gateway.Nodes, newCfg.Gateway.Nodes) + reflect.DeepEqual(cfg.Channels, newCfg.Channels) if runtimeSame { configureLogging(newCfg) @@ -451,7 +395,6 @@ func gatewayCmd() { registryServer.SetToken(cfg.Gateway.Token) registryServer.SetWorkspacePath(cfg.WorkspacePath()) registryServer.SetLogFilePath(cfg.LogFilePath()) - configureGatewayNodeP2P(agentLoop, registryServer, cfg) fmt.Println("Config hot-reload applied (logging/metadata only)") return nil } @@ -480,7 +423,6 @@ func gatewayCmd() { registryServer.SetToken(cfg.Gateway.Token) registryServer.SetWorkspacePath(cfg.WorkspacePath()) registryServer.SetLogFilePath(cfg.LogFilePath()) - configureGatewayNodeP2P(agentLoop, registryServer, cfg) registryServer.SetWhatsAppBridge(whatsAppBridge, embeddedWhatsAppBridgeBasePath) sentinelService.Stop() sentinelService = sentinel.NewService( diff --git a/cmd/cmd_node.go b/cmd/cmd_node.go deleted file mode 100644 index da51a11..0000000 --- a/cmd/cmd_node.go +++ /dev/null @@ -1,1540 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io/fs" - "net/http" - "os" - "os/exec" - "os/signal" - "path/filepath" - "runtime" - "sort" - "strconv" - "strings" - "sync" - "time" - - "github.com/YspCoder/clawgo/pkg/agent" - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/cron" - "github.com/YspCoder/clawgo/pkg/nodes" - "github.com/YspCoder/clawgo/pkg/providers" - "github.com/YspCoder/clawgo/pkg/runtimecfg" - "github.com/YspCoder/clawgo/pkg/tools" - "github.com/gorilla/websocket" - "github.com/pion/webrtc/v4" -) - -type nodeRegisterOptions struct { - GatewayBase string - Token string - NodeToken string - ID string - Name string - Endpoint string - OS string - Arch string - Version string - Actions []string - Models []string - Tags []string - Agents []nodes.AgentInfo - Capabilities nodes.Capabilities - Watch bool - HeartbeatSec int -} - -type nodeHeartbeatOptions struct { - GatewayBase string - Token string - ID string -} - -type nodeWebRTCSession struct { - info nodes.NodeInfo - opts nodeRegisterOptions - client *http.Client - writeJSON func(interface{}) error - - mu sync.Mutex - pc *webrtc.PeerConnection - dc *webrtc.DataChannel -} - -type nodeLocalExecutor struct { - configPath string - workspace string - once sync.Once - loop *agent.AgentLoop - err error -} - -var ( - nodeLocalExecutorMu sync.Mutex - nodeLocalExecutors = map[string]*nodeLocalExecutor{} - nodeProviderFactory = providers.CreateProvider - nodeAgentLoopFactory = agent.NewAgentLoop - nodeLocalExecutorFactory = newNodeLocalExecutor - nodeCameraSnapFunc = captureNodeCameraSnapshot - nodeCameraClipFunc = captureNodeCameraClip - nodeScreenSnapFunc = captureNodeScreenSnapshot - nodeScreenRecordFunc = captureNodeScreenRecord -) - -const nodeArtifactInlineLimit = 512 * 1024 - -func nodeCmd() { - args := os.Args[2:] - if len(args) == 0 { - printNodeHelp() - return - } - switch strings.ToLower(strings.TrimSpace(args[0])) { - case "register": - nodeRegisterCmd(args[1:]) - case "heartbeat": - nodeHeartbeatCmd(args[1:]) - case "help", "--help", "-h": - printNodeHelp() - default: - fmt.Printf("Unknown node command: %s\n", args[0]) - printNodeHelp() - } -} - -func printNodeHelp() { - fmt.Println("Node commands:") - fmt.Println(" clawgo node register [options]") - fmt.Println(" clawgo node heartbeat [options]") - fmt.Println() - fmt.Println("Register options:") - fmt.Println(" --gateway Gateway base URL, e.g. http://host:18790") - fmt.Println(" --token Gateway token (optional when gateway.token is empty)") - fmt.Println(" --node-token Bearer token for this node endpoint (optional)") - fmt.Println(" --id Node ID (default: hostname)") - fmt.Println(" --name Node name (default: hostname)") - fmt.Println(" --endpoint Public endpoint of this node") - fmt.Println(" --os Reported OS (default: current runtime)") - fmt.Println(" --arch Reported arch (default: current runtime)") - fmt.Println(" --version Reported node version (default: current clawgo version)") - fmt.Println(" --actions Supported actions, e.g. run,agent_task") - fmt.Println(" --models Supported models, e.g. gpt-4o-mini") - fmt.Println(" --tags Node tags for dispatch policy, e.g. gpu,vision,build") - fmt.Println(" --capabilities Capability flags: run,invoke,model,camera,screen,location,canvas") - fmt.Println(" --watch Keep a websocket connection open and send heartbeats") - fmt.Println(" --heartbeat-sec Heartbeat interval in seconds when --watch is set (default: 30)") - fmt.Println() - fmt.Println("Heartbeat options:") - fmt.Println(" --gateway Gateway base URL") - fmt.Println(" --token Gateway token") - fmt.Println(" --id Node ID") -} - -func nodeRegisterCmd(args []string) { - cfg, _ := loadConfig() - opts, err := parseNodeRegisterArgs(args, cfg) - if err != nil { - fmt.Printf("Error: %v\n", err) - printNodeHelp() - os.Exit(1) - } - client := &http.Client{Timeout: 20 * time.Second} - info := buildNodeInfo(opts) - ctx := context.Background() - if err := postNodeRegister(ctx, client, opts.GatewayBase, opts.Token, info); err != nil { - fmt.Printf("Error registering node: %v\n", err) - os.Exit(1) - } - fmt.Printf("Node registered: %s -> %s\n", info.ID, opts.GatewayBase) - if !opts.Watch { - return - } - fmt.Printf("Heartbeat loop started: every %ds\n", opts.HeartbeatSec) - if err := runNodeHeartbeatLoop(client, opts, info); err != nil { - fmt.Printf("Heartbeat loop stopped: %v\n", err) - os.Exit(1) - } -} - -func nodeHeartbeatCmd(args []string) { - cfg, _ := loadConfig() - opts, err := parseNodeHeartbeatArgs(args, cfg) - if err != nil { - fmt.Printf("Error: %v\n", err) - printNodeHelp() - os.Exit(1) - } - client := &http.Client{Timeout: 20 * time.Second} - if err := postNodeHeartbeat(context.Background(), client, opts.GatewayBase, opts.Token, opts.ID); err != nil { - fmt.Printf("Error sending heartbeat: %v\n", err) - os.Exit(1) - } - fmt.Printf("Heartbeat sent: %s -> %s\n", opts.ID, opts.GatewayBase) -} - -func parseNodeRegisterArgs(args []string, cfg *config.Config) (nodeRegisterOptions, error) { - host, _ := os.Hostname() - host = strings.TrimSpace(host) - if host == "" { - host = "node" - } - opts := nodeRegisterOptions{ - GatewayBase: defaultGatewayBase(cfg), - Token: defaultGatewayToken(cfg), - ID: host, - Name: host, - OS: runtime.GOOS, - Arch: runtime.GOARCH, - Version: version, - HeartbeatSec: 30, - Capabilities: capabilitiesFromCSV("run,invoke,model"), - } - opts.Agents = nodeAgentsFromConfig(cfg) - for i := 0; i < len(args); i++ { - arg := strings.TrimSpace(args[i]) - next := func() (string, error) { - if i+1 >= len(args) { - return "", fmt.Errorf("missing value for %s", arg) - } - i++ - return strings.TrimSpace(args[i]), nil - } - switch arg { - case "--gateway": - v, err := next() - if err != nil { - return opts, err - } - opts.GatewayBase = v - case "--token": - v, err := next() - if err != nil { - return opts, err - } - opts.Token = v - case "--node-token": - v, err := next() - if err != nil { - return opts, err - } - opts.NodeToken = v - case "--id": - v, err := next() - if err != nil { - return opts, err - } - opts.ID = v - case "--name": - v, err := next() - if err != nil { - return opts, err - } - opts.Name = v - case "--endpoint": - v, err := next() - if err != nil { - return opts, err - } - opts.Endpoint = v - case "--os": - v, err := next() - if err != nil { - return opts, err - } - opts.OS = v - case "--arch": - v, err := next() - if err != nil { - return opts, err - } - opts.Arch = v - case "--version": - v, err := next() - if err != nil { - return opts, err - } - opts.Version = v - case "--actions": - v, err := next() - if err != nil { - return opts, err - } - opts.Actions = splitCSV(v) - case "--models": - v, err := next() - if err != nil { - return opts, err - } - opts.Models = splitCSV(v) - case "--tags": - v, err := next() - if err != nil { - return opts, err - } - opts.Tags = splitCSV(v) - case "--capabilities": - v, err := next() - if err != nil { - return opts, err - } - opts.Capabilities = capabilitiesFromCSV(v) - case "--watch": - opts.Watch = true - case "--heartbeat-sec": - v, err := next() - if err != nil { - return opts, err - } - n, convErr := strconv.Atoi(v) - if convErr != nil || n <= 0 { - return opts, fmt.Errorf("invalid --heartbeat-sec: %s", v) - } - opts.HeartbeatSec = n - default: - return opts, fmt.Errorf("unknown option: %s", arg) - } - } - if strings.TrimSpace(opts.GatewayBase) == "" { - return opts, fmt.Errorf("--gateway is required") - } - if strings.TrimSpace(opts.ID) == "" { - return opts, fmt.Errorf("--id is required") - } - opts.GatewayBase = normalizeGatewayBase(opts.GatewayBase) - return opts, nil -} - -func parseNodeHeartbeatArgs(args []string, cfg *config.Config) (nodeHeartbeatOptions, error) { - host, _ := os.Hostname() - host = strings.TrimSpace(host) - if host == "" { - host = "node" - } - opts := nodeHeartbeatOptions{ - GatewayBase: defaultGatewayBase(cfg), - Token: defaultGatewayToken(cfg), - ID: host, - } - for i := 0; i < len(args); i++ { - arg := strings.TrimSpace(args[i]) - next := func() (string, error) { - if i+1 >= len(args) { - return "", fmt.Errorf("missing value for %s", arg) - } - i++ - return strings.TrimSpace(args[i]), nil - } - switch arg { - case "--gateway": - v, err := next() - if err != nil { - return opts, err - } - opts.GatewayBase = v - case "--token": - v, err := next() - if err != nil { - return opts, err - } - opts.Token = v - case "--id": - v, err := next() - if err != nil { - return opts, err - } - opts.ID = v - default: - return opts, fmt.Errorf("unknown option: %s", arg) - } - } - if strings.TrimSpace(opts.GatewayBase) == "" { - return opts, fmt.Errorf("--gateway is required") - } - if strings.TrimSpace(opts.ID) == "" { - return opts, fmt.Errorf("--id is required") - } - opts.GatewayBase = normalizeGatewayBase(opts.GatewayBase) - return opts, nil -} - -func buildNodeInfo(opts nodeRegisterOptions) nodes.NodeInfo { - return nodes.NodeInfo{ - ID: strings.TrimSpace(opts.ID), - Name: strings.TrimSpace(opts.Name), - Tags: append([]string(nil), opts.Tags...), - OS: strings.TrimSpace(opts.OS), - Arch: strings.TrimSpace(opts.Arch), - Version: strings.TrimSpace(opts.Version), - Endpoint: strings.TrimSpace(opts.Endpoint), - Token: strings.TrimSpace(opts.NodeToken), - Capabilities: opts.Capabilities, - Actions: append([]string(nil), opts.Actions...), - Models: append([]string(nil), opts.Models...), - Agents: append([]nodes.AgentInfo(nil), opts.Agents...), - } -} - -func nodeAgentsFromConfig(cfg *config.Config) []nodes.AgentInfo { - if cfg == nil { - return nil - } - items := make([]nodes.AgentInfo, 0, len(cfg.Agents.Subagents)) - for agentID, subcfg := range cfg.Agents.Subagents { - id := strings.TrimSpace(agentID) - if id == "" || !subcfg.Enabled { - continue - } - items = append(items, nodes.AgentInfo{ - ID: id, - DisplayName: strings.TrimSpace(subcfg.DisplayName), - Role: strings.TrimSpace(subcfg.Role), - Type: strings.TrimSpace(subcfg.Type), - Transport: strings.TrimSpace(subcfg.Transport), - ParentAgentID: strings.TrimSpace(subcfg.ParentAgentID), - }) - } - sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID }) - return items -} - -func runNodeHeartbeatLoop(client *http.Client, opts nodeRegisterOptions, info nodes.NodeInfo) error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - for { - if err := runNodeHeartbeatSocket(ctx, opts, info); err != nil { - if ctx.Err() != nil { - fmt.Println("Node heartbeat stopped") - return nil - } - fmt.Printf("Warning: node socket closed for %s: %v\n", info.ID, err) - } - if ctx.Err() != nil { - fmt.Println("Node heartbeat stopped") - return nil - } - if regErr := postNodeRegister(ctx, client, opts.GatewayBase, opts.Token, info); regErr != nil { - fmt.Printf("Warning: re-register failed for %s: %v\n", info.ID, regErr) - } else { - fmt.Printf("Node re-registered: %s\n", info.ID) - } - select { - case <-ctx.Done(): - fmt.Println("Node heartbeat stopped") - return nil - case <-time.After(2 * time.Second): - } - } -} - -func runNodeHeartbeatSocket(ctx context.Context, opts nodeRegisterOptions, info nodes.NodeInfo) error { - wsURL := nodeWebsocketURL(opts.GatewayBase) - headers := http.Header{} - if strings.TrimSpace(opts.Token) != "" { - headers.Set("Authorization", "Bearer "+strings.TrimSpace(opts.Token)) - } - conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, headers) - if err != nil { - return err - } - defer conn.Close() - var writeMu sync.Mutex - writeJSON := func(v interface{}) error { - writeMu.Lock() - defer writeMu.Unlock() - _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - return conn.WriteJSON(v) - } - writePing := func() error { - writeMu.Lock() - defer writeMu.Unlock() - return conn.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(10*time.Second)) - } - - if err := writeJSON(nodes.WireMessage{Type: "register", Node: &info}); err != nil { - return err - } - acks := make(chan nodes.WireAck, 8) - errs := make(chan error, 1) - client := &http.Client{Timeout: 20 * time.Second} - go readNodeSocketLoop(ctx, conn, writeJSON, client, info, opts, acks, errs) - if err := waitNodeAck(ctx, acks, errs, "registered", info.ID); err != nil { - return err - } - fmt.Printf("Node socket connected: %s\n", info.ID) - - ticker := time.NewTicker(time.Duration(opts.HeartbeatSec) * time.Second) - pingTicker := time.NewTicker(nodeSocketPingInterval(opts.HeartbeatSec)) - defer ticker.Stop() - defer pingTicker.Stop() - - for { - select { - case <-ctx.Done(): - return nil - case err := <-errs: - if err != nil { - return err - } - return nil - case <-pingTicker.C: - if err := writePing(); err != nil { - return err - } - case <-ticker.C: - if err := writeJSON(nodes.WireMessage{Type: "heartbeat", ID: info.ID}); err != nil { - return err - } - if err := waitNodeAck(ctx, acks, errs, "heartbeat", info.ID); err != nil { - return err - } - fmt.Printf("Heartbeat ok: %s\n", info.ID) - } - } -} - -func nodeSocketPingInterval(heartbeatSec int) time.Duration { - if heartbeatSec <= 0 { - return 25 * time.Second - } - interval := time.Duration(heartbeatSec) * time.Second / 2 - if interval < 10*time.Second { - interval = 10 * time.Second - } - if interval > 25*time.Second { - interval = 25 * time.Second - } - return interval -} - -func waitNodeAck(ctx context.Context, acks <-chan nodes.WireAck, errs <-chan error, expectedType, id string) error { - for { - select { - case <-ctx.Done(): - return ctx.Err() - case err := <-errs: - if err == nil { - return context.Canceled - } - return err - case ack := <-acks: - if !ack.OK { - if strings.TrimSpace(ack.Error) == "" { - ack.Error = "unknown websocket error" - } - return fmt.Errorf("%s", ack.Error) - } - ackType := strings.ToLower(strings.TrimSpace(ack.Type)) - if expectedType != "" && ackType != strings.ToLower(strings.TrimSpace(expectedType)) { - continue - } - if strings.TrimSpace(id) != "" && strings.TrimSpace(ack.ID) != "" && strings.TrimSpace(ack.ID) != strings.TrimSpace(id) { - continue - } - return nil - } - } -} - -func readNodeSocketLoop(ctx context.Context, conn *websocket.Conn, writeJSON func(interface{}) error, client *http.Client, info nodes.NodeInfo, opts nodeRegisterOptions, acks chan<- nodes.WireAck, errs chan<- error) { - defer close(acks) - defer close(errs) - rtc := &nodeWebRTCSession{info: info, opts: opts, client: client, writeJSON: writeJSON} - for { - select { - case <-ctx.Done(): - errs <- nil - return - default: - } - _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second)) - _, data, err := conn.ReadMessage() - if err != nil { - errs <- err - return - } - var raw map[string]interface{} - if err := json.Unmarshal(data, &raw); err != nil { - continue - } - if _, hasOK := raw["ok"]; hasOK { - var ack nodes.WireAck - if err := json.Unmarshal(data, &ack); err == nil { - acks <- ack - } - continue - } - var msg nodes.WireMessage - if err := json.Unmarshal(data, &msg); err != nil { - continue - } - switch strings.ToLower(strings.TrimSpace(msg.Type)) { - case "node_request": - go handleNodeWireRequest(ctx, writeJSON, client, info, opts, msg) - case "signal_offer", "signal_answer", "signal_candidate": - if err := rtc.handleSignal(ctx, msg); err != nil { - fmt.Printf("Warning: node webrtc signal failed: %v\n", err) - } - } - } -} - -func (s *nodeWebRTCSession) handleSignal(ctx context.Context, msg nodes.WireMessage) error { - s.mu.Lock() - defer s.mu.Unlock() - - switch strings.ToLower(strings.TrimSpace(msg.Type)) { - case "signal_offer": - pc, err := s.ensurePeerConnectionLocked() - if err != nil { - return err - } - var desc webrtc.SessionDescription - if err := mapWirePayload(msg.Payload, &desc); err != nil { - return err - } - if err := pc.SetRemoteDescription(desc); err != nil { - return err - } - answer, err := pc.CreateAnswer(nil) - if err != nil { - return err - } - if err := pc.SetLocalDescription(answer); err != nil { - return err - } - return s.writeJSON(nodes.WireMessage{ - Type: "signal_answer", - From: s.info.ID, - To: "gateway", - Session: strings.TrimSpace(msg.Session), - Payload: structToWirePayload(*pc.LocalDescription()), - }) - case "signal_candidate": - if s.pc == nil { - return nil - } - var candidate webrtc.ICECandidateInit - if err := mapWirePayload(msg.Payload, &candidate); err != nil { - return err - } - return s.pc.AddICECandidate(candidate) - default: - return nil - } -} - -func (s *nodeWebRTCSession) ensurePeerConnectionLocked() (*webrtc.PeerConnection, error) { - if s.pc != nil { - return s.pc, nil - } - config := webrtc.Configuration{} - pc, err := webrtc.NewPeerConnection(config) - if err != nil { - return nil, err - } - pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { - if candidate == nil { - return - } - _ = s.writeJSON(nodes.WireMessage{ - Type: "signal_candidate", - From: s.info.ID, - To: "gateway", - Session: s.info.ID, - Payload: structToWirePayload(candidate.ToJSON()), - }) - }) - pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { - switch state { - case webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed, webrtc.PeerConnectionStateDisconnected: - s.mu.Lock() - defer s.mu.Unlock() - if s.pc != nil { - _ = s.pc.Close() - } - s.pc = nil - s.dc = nil - } - }) - pc.OnDataChannel(func(dc *webrtc.DataChannel) { - s.mu.Lock() - s.dc = dc - s.mu.Unlock() - dc.OnMessage(func(message webrtc.DataChannelMessage) { - var msg nodes.WireMessage - if err := json.Unmarshal(message.Data, &msg); err != nil { - return - } - if strings.ToLower(strings.TrimSpace(msg.Type)) != "node_request" || msg.Request == nil { - return - } - go s.handleDataChannelRequest(context.Background(), dc, msg) - }) - }) - s.pc = pc - return pc, nil -} - -func (s *nodeWebRTCSession) handleDataChannelRequest(ctx context.Context, dc *webrtc.DataChannel, msg nodes.WireMessage) { - resp := executeNodeRequest(ctx, s.client, s.info, s.opts, msg.Request) - b, err := json.Marshal(nodes.WireMessage{ - Type: "node_response", - ID: msg.ID, - From: s.info.ID, - To: "gateway", - Session: strings.TrimSpace(msg.Session), - Response: &resp, - }) - if err != nil { - return - } - _ = dc.Send(b) -} - -func handleNodeWireRequest(ctx context.Context, writeJSON func(interface{}) error, client *http.Client, info nodes.NodeInfo, opts nodeRegisterOptions, msg nodes.WireMessage) { - resp := executeNodeRequest(ctx, client, info, opts, msg.Request) - _ = writeJSON(nodes.WireMessage{ - Type: "node_response", - ID: msg.ID, - From: info.ID, - To: strings.TrimSpace(msg.From), - Session: strings.TrimSpace(msg.Session), - Response: &resp, - }) -} - -func executeNodeRequest(ctx context.Context, client *http.Client, info nodes.NodeInfo, opts nodeRegisterOptions, req *nodes.Request) nodes.Response { - resp := nodes.Response{ - OK: false, - Code: "invalid_request", - Node: info.ID, - Action: "", - Error: "request missing", - } - if req == nil { - return resp - } - next := *req - resp.Action = next.Action - switch strings.ToLower(strings.TrimSpace(next.Action)) { - case "agent_task": - execResp, err := executeNodeAgentTask(ctx, info, next) - if err == nil { - return execResp - } - if strings.TrimSpace(opts.Endpoint) == "" { - resp.Error = err.Error() - resp.Code = "local_runtime_error" - return resp - } - case "camera_snap": - execResp, err := executeNodeCameraSnap(ctx, info, next) - if err == nil { - return execResp - } - if strings.TrimSpace(opts.Endpoint) == "" { - resp.Error = err.Error() - resp.Code = "local_runtime_error" - return resp - } - case "screen_snapshot": - execResp, err := executeNodeScreenSnapshot(ctx, info, next) - if err == nil { - return execResp - } - if strings.TrimSpace(opts.Endpoint) == "" { - resp.Error = err.Error() - resp.Code = "local_runtime_error" - return resp - } - case "camera_clip": - execResp, err := executeNodeCameraClip(ctx, info, next) - if err == nil { - return execResp - } - if strings.TrimSpace(opts.Endpoint) == "" { - resp.Error = err.Error() - resp.Code = "local_runtime_error" - return resp - } - case "screen_record": - execResp, err := executeNodeScreenRecord(ctx, info, next) - if err == nil { - return execResp - } - if strings.TrimSpace(opts.Endpoint) == "" { - resp.Error = err.Error() - resp.Code = "local_runtime_error" - return resp - } - } - if strings.TrimSpace(opts.Endpoint) == "" { - resp.Error = "node endpoint not configured" - resp.Code = "endpoint_missing" - return resp - } - if next.Node == "" { - next.Node = info.ID - } - execResp, err := nodes.DoEndpointRequest(ctx, client, opts.Endpoint, opts.NodeToken, next) - if err != nil { - return nodes.Response{ - OK: false, - Code: "transport_error", - Node: info.ID, - Action: next.Action, - Error: err.Error(), - } - } - if strings.TrimSpace(execResp.Node) == "" { - execResp.Node = info.ID - } - return execResp -} - -func executeNodeAgentTask(ctx context.Context, info nodes.NodeInfo, req nodes.Request) (nodes.Response, error) { - executor, err := getNodeLocalExecutor() - if err != nil { - return nodes.Response{}, err - } - loop, err := executor.Loop() - if err != nil { - return nodes.Response{}, err - } - - remoteAgentID := strings.TrimSpace(stringArg(req.Args, "remote_agent_id")) - if remoteAgentID == "" || strings.EqualFold(remoteAgentID, "main") { - sessionKey := fmt.Sprintf("node:%s:main", info.ID) - result, err := loop.ProcessDirectWithOptions(ctx, strings.TrimSpace(req.Task), sessionKey, "node", info.ID, "main", nil) - if err != nil { - return nodes.Response{}, err - } - artifacts, err := collectNodeArtifacts(executor.workspace, req.Args) - if err != nil { - return nodes.Response{}, err - } - return nodes.Response{ - OK: true, - Code: "ok", - Node: info.ID, - Action: req.Action, - Payload: map[string]interface{}{ - "transport": "clawgo-local", - "agent_id": "main", - "result": strings.TrimSpace(result), - "artifacts": artifacts, - }, - }, nil - } - - out, err := loop.DispatchSubagentAndWait(ctx, tools.RouterDispatchRequest{ - Task: strings.TrimSpace(req.Task), - AgentID: remoteAgentID, - NotifyMainPolicy: "internal_only", - OriginChannel: "node", - OriginChatID: info.ID, - }, 120*time.Second) - if err != nil { - return nodes.Response{}, err - } - result := strings.TrimSpace(out) - artifacts, err := collectNodeArtifacts(executor.workspace, req.Args) - if err != nil { - return nodes.Response{}, err - } - return nodes.Response{ - OK: true, - Code: "ok", - Node: info.ID, - Action: req.Action, - Payload: map[string]interface{}{ - "transport": "clawgo-local", - "agent_id": remoteAgentID, - "result": result, - "artifacts": artifacts, - }, - }, nil -} - -func executeNodeCameraSnap(ctx context.Context, info nodes.NodeInfo, req nodes.Request) (nodes.Response, error) { - executor, err := getNodeLocalExecutor() - if err != nil { - return nodes.Response{}, err - } - outputPath, err := nodeCameraSnapFunc(ctx, executor.workspace, req.Args) - if err != nil { - return nodes.Response{}, err - } - artifact, err := buildNodeArtifact(executor.workspace, outputPath) - if err != nil { - return nodes.Response{}, err - } - return nodes.Response{ - OK: true, - Code: "ok", - Node: info.ID, - Action: req.Action, - Payload: map[string]interface{}{ - "transport": "clawgo-local", - "media_type": "image", - "storage": artifact["storage"], - "artifacts": []map[string]interface{}{artifact}, - "meta": map[string]interface{}{ - "facing": stringArg(req.Args, "facing"), - }, - }, - }, nil -} - -func executeNodeScreenSnapshot(ctx context.Context, info nodes.NodeInfo, req nodes.Request) (nodes.Response, error) { - executor, err := getNodeLocalExecutor() - if err != nil { - return nodes.Response{}, err - } - outputPath, err := nodeScreenSnapFunc(ctx, executor.workspace, req.Args) - if err != nil { - return nodes.Response{}, err - } - artifact, err := buildNodeArtifact(executor.workspace, outputPath) - if err != nil { - return nodes.Response{}, err - } - return nodes.Response{ - OK: true, - Code: "ok", - Node: info.ID, - Action: req.Action, - Payload: map[string]interface{}{ - "transport": "clawgo-local", - "media_type": "image", - "storage": artifact["storage"], - "artifacts": []map[string]interface{}{artifact}, - }, - }, nil -} - -func executeNodeCameraClip(ctx context.Context, info nodes.NodeInfo, req nodes.Request) (nodes.Response, error) { - executor, err := getNodeLocalExecutor() - if err != nil { - return nodes.Response{}, err - } - durationMs := durationArg(req.Args, "duration_ms", 3000) - outputPath, err := nodeCameraClipFunc(ctx, executor.workspace, req.Args) - if err != nil { - return nodes.Response{}, err - } - artifact, err := buildNodeArtifact(executor.workspace, outputPath) - if err != nil { - return nodes.Response{}, err - } - return nodes.Response{ - OK: true, - Code: "ok", - Node: info.ID, - Action: req.Action, - Payload: map[string]interface{}{ - "transport": "clawgo-local", - "media_type": "video", - "storage": artifact["storage"], - "duration_ms": durationMs, - "artifacts": []map[string]interface{}{artifact}, - "meta": map[string]interface{}{ - "facing": stringArg(req.Args, "facing"), - }, - }, - }, nil -} - -func executeNodeScreenRecord(ctx context.Context, info nodes.NodeInfo, req nodes.Request) (nodes.Response, error) { - executor, err := getNodeLocalExecutor() - if err != nil { - return nodes.Response{}, err - } - durationMs := durationArg(req.Args, "duration_ms", 3000) - outputPath, err := nodeScreenRecordFunc(ctx, executor.workspace, req.Args) - if err != nil { - return nodes.Response{}, err - } - artifact, err := buildNodeArtifact(executor.workspace, outputPath) - if err != nil { - return nodes.Response{}, err - } - return nodes.Response{ - OK: true, - Code: "ok", - Node: info.ID, - Action: req.Action, - Payload: map[string]interface{}{ - "transport": "clawgo-local", - "media_type": "video", - "storage": artifact["storage"], - "duration_ms": durationMs, - "artifacts": []map[string]interface{}{artifact}, - }, - }, nil -} - -func getNodeLocalExecutor() (*nodeLocalExecutor, error) { - key := strings.TrimSpace(getConfigPath()) - if key == "" { - return nil, fmt.Errorf("config path is required") - } - nodeLocalExecutorMu.Lock() - defer nodeLocalExecutorMu.Unlock() - if existing := nodeLocalExecutors[key]; existing != nil { - return existing, nil - } - exec, err := nodeLocalExecutorFactory(key) - if err != nil { - return nil, err - } - nodeLocalExecutors[key] = exec - return exec, nil -} - -func newNodeLocalExecutor(configPath string) (*nodeLocalExecutor, error) { - configPath = strings.TrimSpace(configPath) - if configPath == "" { - return nil, fmt.Errorf("config path is required") - } - return &nodeLocalExecutor{configPath: configPath}, nil -} - -func (e *nodeLocalExecutor) Loop() (*agent.AgentLoop, error) { - if e == nil { - return nil, fmt.Errorf("node local executor is nil") - } - e.once.Do(func() { - prev := globalConfigPathOverride - globalConfigPathOverride = e.configPath - defer func() { globalConfigPathOverride = prev }() - - cfg, err := loadConfig() - if err != nil { - e.err = err - return - } - runtimecfg.Set(cfg) - msgBus := bus.NewMessageBus() - cronStorePath := filepath.Join(filepath.Dir(e.configPath), "cron", "jobs.json") - cronService := cron.NewCronService(cronStorePath, nil) - configureCronServiceRuntime(cronService, cfg) - provider, err := nodeProviderFactory(cfg) - if err != nil { - e.err = err - return - } - e.workspace = cfg.WorkspacePath() - loop := nodeAgentLoopFactory(cfg, msgBus, provider, cronService) - loop.SetConfigPath(e.configPath) - e.loop = loop - }) - if e.err != nil { - return nil, e.err - } - if e.loop == nil { - return nil, fmt.Errorf("node local executor unavailable") - } - return e.loop, nil -} - -func stringArg(args map[string]interface{}, key string) string { - if len(args) == 0 { - return "" - } - value, ok := args[key] - if !ok || value == nil { - return "" - } - return strings.TrimSpace(fmt.Sprint(value)) -} - -func collectNodeArtifacts(workspace string, args map[string]interface{}) ([]map[string]interface{}, error) { - paths := stringListArg(args, "artifact_paths") - if len(paths) == 0 { - return []map[string]interface{}{}, nil - } - root := strings.TrimSpace(workspace) - if root == "" { - return nil, fmt.Errorf("workspace path not configured") - } - out := make([]map[string]interface{}, 0, len(paths)) - for _, raw := range paths { - artifact, err := buildNodeArtifact(root, raw) - if err != nil { - return nil, err - } - out = append(out, artifact) - } - return out, nil -} - -func buildNodeArtifact(workspace, rawPath string) (map[string]interface{}, error) { - rawPath = strings.TrimSpace(rawPath) - if rawPath == "" { - return nil, fmt.Errorf("artifact path is required") - } - clean := filepath.Clean(rawPath) - fullPath := clean - if !filepath.IsAbs(clean) { - fullPath = filepath.Join(workspace, clean) - } - fullPath = filepath.Clean(fullPath) - rel, err := filepath.Rel(workspace, fullPath) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { - return nil, fmt.Errorf("artifact path escapes workspace: %s", rawPath) - } - info, err := os.Stat(fullPath) - if err != nil { - return nil, err - } - if info.IsDir() { - return nil, fmt.Errorf("artifact path must be file: %s", rawPath) - } - artifact := map[string]interface{}{ - "name": filepath.Base(fullPath), - "kind": nodeArtifactKindFromPath(fullPath), - "source_path": filepath.ToSlash(rel), - "size_bytes": info.Size(), - } - if mimeType := mimeTypeForPath(fullPath); mimeType != "" { - artifact["mime_type"] = mimeType - } - data, err := os.ReadFile(fullPath) - if err != nil { - return nil, err - } - if shouldInlineAsText(fullPath, data, info.Mode()) { - artifact["storage"] = "inline" - artifact["content_text"] = string(data) - return artifact, nil - } - artifact["storage"] = "inline" - if len(data) > nodeArtifactInlineLimit { - data = data[:nodeArtifactInlineLimit] - artifact["truncated"] = true - } - artifact["content_base64"] = base64.StdEncoding.EncodeToString(data) - return artifact, nil -} - -func stringListArg(args map[string]interface{}, key string) []string { - if len(args) == 0 { - return nil - } - items, ok := args[key].([]interface{}) - if !ok { - return nil - } - out := make([]string, 0, len(items)) - for _, item := range items { - value := strings.TrimSpace(fmt.Sprint(item)) - if value == "" { - continue - } - out = append(out, value) - } - return out -} - -func mimeTypeForPath(path string) string { - switch strings.ToLower(filepath.Ext(path)) { - case ".md": - return "text/markdown" - case ".txt", ".log", ".json", ".yaml", ".yml", ".xml", ".csv": - return "text/plain" - case ".png": - return "image/png" - case ".jpg", ".jpeg": - return "image/jpeg" - case ".gif": - return "image/gif" - case ".webp": - return "image/webp" - case ".mp4": - return "video/mp4" - case ".mov": - return "video/quicktime" - case ".pdf": - return "application/pdf" - default: - return "" - } -} - -func nodeArtifactKindFromPath(path string) string { - ext := strings.ToLower(filepath.Ext(path)) - switch ext { - case ".png", ".jpg", ".jpeg", ".gif", ".webp": - return "image" - case ".mp4", ".mov", ".webm": - return "video" - case ".pdf": - return "document" - default: - return "file" - } -} - -func shouldInlineAsText(path string, data []byte, mode fs.FileMode) bool { - if mode&fs.ModeType != 0 { - return false - } - switch strings.ToLower(filepath.Ext(path)) { - case ".md", ".txt", ".log", ".json", ".yaml", ".yml", ".xml", ".csv", ".go", ".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".sh": - return len(data) <= nodeArtifactInlineLimit - default: - return false - } -} - -func captureNodeCameraSnapshot(ctx context.Context, workspace string, args map[string]interface{}) (string, error) { - outputPath, err := nodeMediaOutputPath(workspace, "camera", ".jpg", stringArg(args, "filename")) - if err != nil { - return "", err - } - switch runtime.GOOS { - case "linux": - if _, err := os.Stat("/dev/video0"); err != nil { - return "", fmt.Errorf("camera device /dev/video0 not found") - } - if _, err := exec.LookPath("ffmpeg"); err != nil { - return "", fmt.Errorf("ffmpeg not installed") - } - cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "video4linux2", "-i", "/dev/video0", "-vframes", "1", "-q:v", "2", outputPath) - if out, err := cmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("camera capture failed: %v, output=%s", err, strings.TrimSpace(string(out))) - } - return outputPath, nil - case "darwin": - if _, err := exec.LookPath("imagesnap"); err != nil { - return "", fmt.Errorf("imagesnap not installed") - } - cmd := exec.CommandContext(ctx, "imagesnap", "-q", outputPath) - if out, err := cmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("camera capture failed: %v, output=%s", err, strings.TrimSpace(string(out))) - } - return outputPath, nil - default: - return "", fmt.Errorf("camera_snap not supported on %s", runtime.GOOS) - } -} - -func captureNodeScreenSnapshot(ctx context.Context, workspace string, args map[string]interface{}) (string, error) { - outputPath, err := nodeMediaOutputPath(workspace, "screen", ".png", stringArg(args, "filename")) - if err != nil { - return "", err - } - switch runtime.GOOS { - case "darwin": - cmd := exec.CommandContext(ctx, "screencapture", "-x", outputPath) - if out, err := cmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("screen capture failed: %v, output=%s", err, strings.TrimSpace(string(out))) - } - return outputPath, nil - case "linux": - candidates := [][]string{ - {"grim", outputPath}, - {"gnome-screenshot", "-f", outputPath}, - {"scrot", outputPath}, - {"import", "-window", "root", outputPath}, - } - for _, candidate := range candidates { - if _, err := exec.LookPath(candidate[0]); err != nil { - continue - } - cmd := exec.CommandContext(ctx, candidate[0], candidate[1:]...) - if out, err := cmd.CombinedOutput(); err == nil { - return outputPath, nil - } else if strings.TrimSpace(string(out)) != "" { - continue - } - } - return "", fmt.Errorf("no supported screen capture command found (grim, gnome-screenshot, scrot, import)") - default: - return "", fmt.Errorf("screen_snapshot not supported on %s", runtime.GOOS) - } -} - -func captureNodeCameraClip(ctx context.Context, workspace string, args map[string]interface{}) (string, error) { - outputPath, err := nodeMediaOutputPath(workspace, "camera", ".mp4", stringArg(args, "filename")) - if err != nil { - return "", err - } - durationSec := fmt.Sprintf("%.3f", float64(durationArg(args, "duration_ms", 3000))/1000.0) - switch runtime.GOOS { - case "linux": - if _, err := os.Stat("/dev/video0"); err != nil { - return "", fmt.Errorf("camera device /dev/video0 not found") - } - if _, err := exec.LookPath("ffmpeg"); err != nil { - return "", fmt.Errorf("ffmpeg not installed") - } - cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "video4linux2", "-t", durationSec, "-i", "/dev/video0", "-pix_fmt", "yuv420p", outputPath) - if out, err := cmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("camera clip failed: %v, output=%s", err, strings.TrimSpace(string(out))) - } - return outputPath, nil - case "darwin": - if _, err := exec.LookPath("ffmpeg"); err != nil { - return "", fmt.Errorf("ffmpeg not installed") - } - cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "avfoundation", "-t", durationSec, "-i", "0:none", "-pix_fmt", "yuv420p", outputPath) - if out, err := cmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("camera clip failed: %v, output=%s", err, strings.TrimSpace(string(out))) - } - return outputPath, nil - default: - return "", fmt.Errorf("camera_clip not supported on %s", runtime.GOOS) - } -} - -func captureNodeScreenRecord(ctx context.Context, workspace string, args map[string]interface{}) (string, error) { - outputPath, err := nodeMediaOutputPath(workspace, "screen", ".mp4", stringArg(args, "filename")) - if err != nil { - return "", err - } - durationMs := durationArg(args, "duration_ms", 3000) - durationSec := fmt.Sprintf("%.3f", float64(durationMs)/1000.0) - durationWholeSec := strconv.Itoa((durationMs + 999) / 1000) - switch runtime.GOOS { - case "darwin": - if _, err := exec.LookPath("ffmpeg"); err == nil { - cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-f", "avfoundation", "-t", durationSec, "-i", "1:none", "-pix_fmt", "yuv420p", outputPath) - if out, err := cmd.CombinedOutput(); err == nil { - return outputPath, nil - } else if strings.TrimSpace(string(out)) != "" { - return "", fmt.Errorf("screen record failed: %v, output=%s", err, strings.TrimSpace(string(out))) - } - } - return "", fmt.Errorf("ffmpeg not installed") - case "linux": - candidates := [][]string{ - {"ffmpeg", "-y", "-f", "x11grab", "-t", durationSec, "-i", os.Getenv("DISPLAY"), "-pix_fmt", "yuv420p", outputPath}, - {"wf-recorder", "-f", outputPath, "-d", durationWholeSec}, - } - for _, candidate := range candidates { - if candidate[0] == "ffmpeg" && strings.TrimSpace(os.Getenv("DISPLAY")) == "" { - continue - } - if _, err := exec.LookPath(candidate[0]); err != nil { - continue - } - cmd := exec.CommandContext(ctx, candidate[0], candidate[1:]...) - if out, err := cmd.CombinedOutput(); err == nil { - return outputPath, nil - } else if strings.TrimSpace(string(out)) != "" && candidate[0] == "ffmpeg" { - continue - } - } - return "", fmt.Errorf("no supported screen recorder found (ffmpeg x11grab or wf-recorder)") - default: - return "", fmt.Errorf("screen_record not supported on %s", runtime.GOOS) - } -} - -func nodeMediaOutputPath(workspace, kind, ext, requested string) (string, error) { - root := strings.TrimSpace(workspace) - if root == "" { - return "", fmt.Errorf("workspace path not configured") - } - baseDir := filepath.Join(root, "artifacts", "node") - if err := os.MkdirAll(baseDir, 0755); err != nil { - return "", err - } - filename := strings.TrimSpace(requested) - if filename == "" { - filename = fmt.Sprintf("%s_%d%s", kind, time.Now().UnixNano(), ext) - } - filename = filepath.Clean(filename) - if filepath.IsAbs(filename) { - return "", fmt.Errorf("filename must be relative to workspace") - } - fullPath := filepath.Join(baseDir, filename) - fullPath = filepath.Clean(fullPath) - rel, err := filepath.Rel(root, fullPath) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { - return "", fmt.Errorf("capture path escapes workspace") - } - if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { - return "", err - } - return fullPath, nil -} - -func durationArg(args map[string]interface{}, key string, fallback int) int { - if len(args) == 0 { - return fallback - } - switch v := args[key].(type) { - case int: - if v > 0 { - return v - } - case int64: - if v > 0 { - return int(v) - } - case float64: - if v > 0 { - return int(v) - } - case json.Number: - if n, err := v.Int64(); err == nil && n > 0 { - return int(n) - } - } - return fallback -} - -func structToWirePayload(v interface{}) map[string]interface{} { - b, _ := json.Marshal(v) - var out map[string]interface{} - _ = json.Unmarshal(b, &out) - if out == nil { - out = map[string]interface{}{} - } - return out -} - -func mapWirePayload(in map[string]interface{}, out interface{}) error { - if len(in) == 0 { - return fmt.Errorf("empty payload") - } - b, err := json.Marshal(in) - if err != nil { - return err - } - return json.Unmarshal(b, out) -} - -func postNodeRegister(ctx context.Context, client *http.Client, gatewayBase, token string, info nodes.NodeInfo) error { - return postNodeJSON(ctx, client, gatewayBase, token, "/nodes/register", info) -} - -func postNodeHeartbeat(ctx context.Context, client *http.Client, gatewayBase, token, id string) error { - return postNodeJSON(ctx, client, gatewayBase, token, "/nodes/heartbeat", map[string]string{"id": strings.TrimSpace(id)}) -} - -func postNodeJSON(ctx context.Context, client *http.Client, gatewayBase, token, path string, payload interface{}) error { - body, err := json.Marshal(payload) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(gatewayBase, "/")+path, bytes.NewReader(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - if strings.TrimSpace(token) != "" { - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) - } - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - var out bytes.Buffer - _, _ = out.ReadFrom(resp.Body) - msg := strings.TrimSpace(out.String()) - if msg == "" { - msg = resp.Status - } - return fmt.Errorf("http %d: %s", resp.StatusCode, msg) - } - return nil -} - -func defaultGatewayBase(cfg *config.Config) string { - if raw := strings.TrimSpace(os.Getenv("CLAWGO_GATEWAY_URL")); raw != "" { - return normalizeGatewayBase(raw) - } - host := "127.0.0.1" - port := 18790 - if cfg != nil { - if v := strings.TrimSpace(cfg.Gateway.Host); v != "" && v != "0.0.0.0" && v != "::" { - host = v - } - if cfg.Gateway.Port > 0 { - port = cfg.Gateway.Port - } - } - return fmt.Sprintf("http://%s:%d", host, port) -} - -func defaultGatewayToken(cfg *config.Config) string { - if v := strings.TrimSpace(os.Getenv("CLAWGO_GATEWAY_TOKEN")); v != "" { - return v - } - if cfg == nil { - return "" - } - return strings.TrimSpace(cfg.Gateway.Token) -} - -func normalizeGatewayBase(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") { - raw = "http://" + raw - } - return strings.TrimRight(raw, "/") -} - -func nodeWebsocketURL(gatewayBase string) string { - base := normalizeGatewayBase(gatewayBase) - base = strings.TrimPrefix(base, "http://") - base = strings.TrimPrefix(base, "https://") - scheme := "ws://" - if strings.HasPrefix(strings.TrimSpace(gatewayBase), "https://") { - scheme = "wss://" - } - return scheme + base + "/nodes/connect" -} - -func splitCSV(raw string) []string { - parts := strings.Split(raw, ",") - out := make([]string, 0, len(parts)) - seen := map[string]bool{} - for _, part := range parts { - item := strings.TrimSpace(part) - if item == "" || seen[item] { - continue - } - seen[item] = true - out = append(out, item) - } - return out -} - -func capabilitiesFromCSV(raw string) nodes.Capabilities { - caps := nodes.Capabilities{} - for _, item := range splitCSV(raw) { - switch strings.ToLower(item) { - case "run": - caps.Run = true - case "invoke": - caps.Invoke = true - case "model", "agent_task": - caps.Model = true - case "camera": - caps.Camera = true - case "screen": - caps.Screen = true - case "location": - caps.Location = true - case "canvas": - caps.Canvas = true - } - } - return caps -} diff --git a/cmd/cmd_node_test.go b/cmd/cmd_node_test.go deleted file mode 100644 index 400643a..0000000 --- a/cmd/cmd_node_test.go +++ /dev/null @@ -1,497 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/YspCoder/clawgo/pkg/agent" - "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/nodes" - "github.com/YspCoder/clawgo/pkg/providers" -) - -type stubNodeProvider struct { - content string -} - -func (p stubNodeProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) { - return &providers.LLMResponse{Content: p.content, FinishReason: "stop"}, nil -} - -func (p stubNodeProvider) GetDefaultModel() string { - return "stub-model" -} - -func TestParseNodeRegisterArgsDefaults(t *testing.T) { - t.Parallel() - - cfg := config.DefaultConfig() - cfg.Gateway.Host = "gateway.example" - cfg.Gateway.Port = 7788 - cfg.Gateway.Token = "cfg-token" - - opts, err := parseNodeRegisterArgs([]string{"--id", "edge-dev"}, cfg) - if err != nil { - t.Fatalf("parseNodeRegisterArgs failed: %v", err) - } - if opts.GatewayBase != "http://gateway.example:7788" { - t.Fatalf("unexpected gateway base: %s", opts.GatewayBase) - } - if opts.Token != "cfg-token" { - t.Fatalf("unexpected token: %s", opts.Token) - } - if opts.ID != "edge-dev" { - t.Fatalf("unexpected id: %s", opts.ID) - } - if !opts.Capabilities.Run || !opts.Capabilities.Invoke || !opts.Capabilities.Model { - t.Fatalf("expected default run/invoke/model capabilities, got %+v", opts.Capabilities) - } -} - -func TestParseNodeRegisterArgsTags(t *testing.T) { - t.Parallel() - - cfg := config.DefaultConfig() - opts, err := parseNodeRegisterArgs([]string{"--id", "edge-dev", "--tags", "vision,gpu"}, cfg) - if err != nil { - t.Fatalf("parseNodeRegisterArgs failed: %v", err) - } - if len(opts.Tags) != 2 || opts.Tags[0] != "vision" || opts.Tags[1] != "gpu" { - t.Fatalf("unexpected tags: %+v", opts.Tags) - } -} - -func TestPostNodeRegisterSendsNodeInfo(t *testing.T) { - t.Parallel() - - var gotAuth string - var got nodes.NodeInfo - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/nodes/register" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - gotAuth = r.Header.Get("Authorization") - if err := json.NewDecoder(r.Body).Decode(&got); err != nil { - t.Fatalf("decode body: %v", err) - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer srv.Close() - - info := nodes.NodeInfo{ - ID: "edge-dev", - Name: "Edge Dev", - Endpoint: "http://edge.example:18790", - Capabilities: nodes.Capabilities{ - Run: true, Invoke: true, Model: true, - }, - Actions: []string{"run", "agent_task"}, - Models: []string{"gpt-4o-mini"}, - } - client := &http.Client{Timeout: 2 * time.Second} - if err := postNodeRegister(context.Background(), client, srv.URL, "secret", info); err != nil { - t.Fatalf("postNodeRegister failed: %v", err) - } - if gotAuth != "Bearer secret" { - t.Fatalf("unexpected auth header: %s", gotAuth) - } - if got.ID != "edge-dev" || got.Endpoint != "http://edge.example:18790" { - t.Fatalf("unexpected node payload: %+v", got) - } - if len(got.Actions) != 2 || got.Actions[1] != "agent_task" { - t.Fatalf("unexpected actions: %+v", got.Actions) - } -} - -func TestPostNodeHeartbeatSendsNodeID(t *testing.T) { - t.Parallel() - - var body map[string]string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/nodes/heartbeat" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode body: %v", err) - } - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - client := &http.Client{Timeout: 2 * time.Second} - if err := postNodeHeartbeat(context.Background(), client, srv.URL, "", "edge-dev"); err != nil { - t.Fatalf("postNodeHeartbeat failed: %v", err) - } - if strings.TrimSpace(body["id"]) != "edge-dev" { - t.Fatalf("unexpected heartbeat body: %+v", body) - } -} - -func TestNodeAgentsFromConfigCollectsEnabledAgents(t *testing.T) { - t.Parallel() - - cfg := config.DefaultConfig() - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - DisplayName: "Main Agent", - Role: "orchestrator", - } - cfg.Agents.Subagents["coder"] = config.SubagentConfig{ - Enabled: true, - Type: "worker", - DisplayName: "Code Agent", - Role: "code", - } - cfg.Agents.Subagents["tester"] = config.SubagentConfig{ - Enabled: false, - Type: "worker", - DisplayName: "Test Agent", - Role: "test", - } - items := nodeAgentsFromConfig(cfg) - if len(items) != 2 { - t.Fatalf("expected 2 enabled agents, got %+v", items) - } - if items[0].ID != "coder" || items[1].ID != "main" { - t.Fatalf("unexpected agent export order: %+v", items) - } -} - -func TestNodeWebsocketURL(t *testing.T) { - t.Parallel() - - if got := nodeWebsocketURL("http://gateway.example:18790"); got != "ws://gateway.example:18790/nodes/connect" { - t.Fatalf("unexpected ws url: %s", got) - } - if got := nodeWebsocketURL("https://gateway.example"); got != "wss://gateway.example/nodes/connect" { - t.Fatalf("unexpected wss url: %s", got) - } -} - -func TestNodeSocketPingInterval(t *testing.T) { - t.Parallel() - - if got := nodeSocketPingInterval(120); got != 25*time.Second { - t.Fatalf("expected 25s cap, got %s", got) - } - if got := nodeSocketPingInterval(20); got != 10*time.Second { - t.Fatalf("expected 10s floor, got %s", got) - } - if got := nodeSocketPingInterval(30); got != 15*time.Second { - t.Fatalf("expected half heartbeat, got %s", got) - } -} - -func TestExecuteNodeRequestRunsLocalMainAgentTask(t *testing.T) { - prevCfg := globalConfigPathOverride - prevProviderFactory := nodeProviderFactory - prevLoopFactory := nodeAgentLoopFactory - prevExecutors := nodeLocalExecutors - globalConfigPathOverride = filepath.Join(t.TempDir(), "config.json") - nodeLocalExecutors = map[string]*nodeLocalExecutor{} - nodeProviderFactory = func(cfg *config.Config) (providers.LLMProvider, error) { - return stubNodeProvider{content: "main-local-ok"}, nil - } - nodeAgentLoopFactory = agent.NewAgentLoop - defer func() { - globalConfigPathOverride = prevCfg - nodeProviderFactory = prevProviderFactory - nodeAgentLoopFactory = prevLoopFactory - nodeLocalExecutors = prevExecutors - }() - - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace") - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - } - if err := config.SaveConfig(globalConfigPathOverride, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - - info := nodes.NodeInfo{ID: "edge-a", Name: "Edge A"} - resp := executeNodeRequest(context.Background(), &http.Client{Timeout: time.Second}, info, nodeRegisterOptions{}, &nodes.Request{ - Action: "agent_task", - Task: "say ok", - }) - if !resp.OK { - t.Fatalf("expected ok response, got %+v", resp) - } - if got := strings.TrimSpace(resp.Payload["result"].(string)); got != "main-local-ok" { - t.Fatalf("unexpected result: %+v", resp.Payload) - } - if got := strings.TrimSpace(resp.Payload["agent_id"].(string)); got != "main" { - t.Fatalf("unexpected agent id: %+v", resp.Payload) - } -} - -func TestExecuteNodeRequestRunsLocalSubagentRun(t *testing.T) { - prevCfg := globalConfigPathOverride - prevProviderFactory := nodeProviderFactory - prevLoopFactory := nodeAgentLoopFactory - prevExecutors := nodeLocalExecutors - globalConfigPathOverride = filepath.Join(t.TempDir(), "config.json") - nodeLocalExecutors = map[string]*nodeLocalExecutor{} - nodeProviderFactory = func(cfg *config.Config) (providers.LLMProvider, error) { - return stubNodeProvider{content: "coder-local-ok"}, nil - } - nodeAgentLoopFactory = agent.NewAgentLoop - defer func() { - globalConfigPathOverride = prevCfg - nodeProviderFactory = prevProviderFactory - nodeAgentLoopFactory = prevLoopFactory - nodeLocalExecutors = prevExecutors - }() - - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace") - cfg.Agents.Subagents["main"] = config.SubagentConfig{ - Enabled: true, - Type: "router", - Role: "orchestrator", - } - cfg.Agents.Subagents["coder"] = config.SubagentConfig{ - Enabled: true, - Type: "worker", - Role: "code", - } - if err := os.MkdirAll(filepath.Join(cfg.Agents.Defaults.Workspace, "out"), 0755); err != nil { - t.Fatalf("mkdir artifact dir: %v", err) - } - if err := os.WriteFile(filepath.Join(cfg.Agents.Defaults.Workspace, "out", "result.txt"), []byte("artifact-body"), 0644); err != nil { - t.Fatalf("write artifact: %v", err) - } - if err := config.SaveConfig(globalConfigPathOverride, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - - info := nodes.NodeInfo{ID: "edge-b", Name: "Edge B"} - resp := executeNodeRequest(context.Background(), &http.Client{Timeout: time.Second}, info, nodeRegisterOptions{}, &nodes.Request{ - Action: "agent_task", - Task: "write tests", - Args: map[string]interface{}{"remote_agent_id": "coder", "artifact_paths": []interface{}{"out/result.txt"}}, - }) - if !resp.OK { - t.Fatalf("expected ok response, got %+v", resp) - } - if got := strings.TrimSpace(resp.Payload["result"].(string)); !strings.Contains(got, "coder-local-ok") { - t.Fatalf("unexpected result: %+v", resp.Payload) - } - if got := strings.TrimSpace(resp.Payload["agent_id"].(string)); got != "coder" { - t.Fatalf("unexpected agent id: %+v", resp.Payload) - } - artifacts, ok := resp.Payload["artifacts"].([]map[string]interface{}) - if !ok || len(artifacts) != 1 { - t.Fatalf("expected one artifact, got %+v", resp.Payload["artifacts"]) - } - if artifacts[0]["content_text"] != "artifact-body" { - t.Fatalf("unexpected artifact payload: %+v", artifacts[0]) - } -} - -func TestCollectNodeArtifactsRejectsPathEscape(t *testing.T) { - t.Parallel() - - _, err := collectNodeArtifacts(t.TempDir(), map[string]interface{}{ - "artifact_paths": []interface{}{"../secret.txt"}, - }) - if err == nil || !strings.Contains(err.Error(), "escapes workspace") { - t.Fatalf("expected workspace escape error, got %v", err) - } -} - -func TestExecuteNodeRequestRunsLocalCameraSnap(t *testing.T) { - prevCfg := globalConfigPathOverride - prevExecutors := nodeLocalExecutors - prevCamera := nodeCameraSnapFunc - globalConfigPathOverride = filepath.Join(t.TempDir(), "config.json") - nodeLocalExecutors = map[string]*nodeLocalExecutor{} - defer func() { - globalConfigPathOverride = prevCfg - nodeLocalExecutors = prevExecutors - nodeCameraSnapFunc = prevCamera - }() - - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace") - if err := config.SaveConfig(globalConfigPathOverride, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - nodeCameraSnapFunc = func(ctx context.Context, workspace string, args map[string]interface{}) (string, error) { - out := filepath.Join(workspace, "artifacts", "node", "camera-test.jpg") - if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil { - return "", err - } - if err := os.WriteFile(out, []byte("camera-bytes"), 0644); err != nil { - return "", err - } - return out, nil - } - - info := nodes.NodeInfo{ID: "edge-cam", Name: "Edge Cam"} - resp := executeNodeRequest(context.Background(), &http.Client{Timeout: time.Second}, info, nodeRegisterOptions{}, &nodes.Request{ - Action: "camera_snap", - Args: map[string]interface{}{"facing": "front"}, - }) - if !resp.OK { - t.Fatalf("expected ok response, got %+v", resp) - } - artifacts, ok := resp.Payload["artifacts"].([]map[string]interface{}) - if !ok || len(artifacts) != 1 { - t.Fatalf("expected one artifact, got %+v", resp.Payload["artifacts"]) - } - if artifacts[0]["name"] != "camera-test.jpg" { - t.Fatalf("unexpected artifact: %+v", artifacts[0]) - } -} - -func TestExecuteNodeRequestRunsLocalScreenSnapshot(t *testing.T) { - prevCfg := globalConfigPathOverride - prevExecutors := nodeLocalExecutors - prevScreen := nodeScreenSnapFunc - globalConfigPathOverride = filepath.Join(t.TempDir(), "config.json") - nodeLocalExecutors = map[string]*nodeLocalExecutor{} - defer func() { - globalConfigPathOverride = prevCfg - nodeLocalExecutors = prevExecutors - nodeScreenSnapFunc = prevScreen - }() - - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace") - if err := config.SaveConfig(globalConfigPathOverride, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - nodeScreenSnapFunc = func(ctx context.Context, workspace string, args map[string]interface{}) (string, error) { - out := filepath.Join(workspace, "artifacts", "node", "screen-test.png") - if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil { - return "", err - } - if err := os.WriteFile(out, []byte{0x89, 0x50, 0x4e, 0x47}, 0644); err != nil { - return "", err - } - return out, nil - } - - info := nodes.NodeInfo{ID: "edge-screen", Name: "Edge Screen"} - resp := executeNodeRequest(context.Background(), &http.Client{Timeout: time.Second}, info, nodeRegisterOptions{}, &nodes.Request{ - Action: "screen_snapshot", - }) - if !resp.OK { - t.Fatalf("expected ok response, got %+v", resp) - } - artifacts, ok := resp.Payload["artifacts"].([]map[string]interface{}) - if !ok || len(artifacts) != 1 { - t.Fatalf("expected one artifact, got %+v", resp.Payload["artifacts"]) - } - if artifacts[0]["name"] != "screen-test.png" { - t.Fatalf("unexpected artifact: %+v", artifacts[0]) - } -} - -func TestExecuteNodeRequestRunsLocalCameraClip(t *testing.T) { - prevCfg := globalConfigPathOverride - prevExecutors := nodeLocalExecutors - prevClip := nodeCameraClipFunc - globalConfigPathOverride = filepath.Join(t.TempDir(), "config.json") - nodeLocalExecutors = map[string]*nodeLocalExecutor{} - defer func() { - globalConfigPathOverride = prevCfg - nodeLocalExecutors = prevExecutors - nodeCameraClipFunc = prevClip - }() - - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace") - if err := config.SaveConfig(globalConfigPathOverride, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - nodeCameraClipFunc = func(ctx context.Context, workspace string, args map[string]interface{}) (string, error) { - out := filepath.Join(workspace, "artifacts", "node", "camera-test.mp4") - if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil { - return "", err - } - if err := os.WriteFile(out, []byte("video-bytes"), 0644); err != nil { - return "", err - } - return out, nil - } - - info := nodes.NodeInfo{ID: "edge-clip", Name: "Edge Clip"} - resp := executeNodeRequest(context.Background(), &http.Client{Timeout: time.Second}, info, nodeRegisterOptions{}, &nodes.Request{ - Action: "camera_clip", - Args: map[string]interface{}{"duration_ms": 2500}, - }) - if !resp.OK { - t.Fatalf("expected ok response, got %+v", resp) - } - if got, _ := resp.Payload["duration_ms"].(int); got != 2500 { - t.Fatalf("unexpected duration payload: %+v", resp.Payload) - } - artifacts, ok := resp.Payload["artifacts"].([]map[string]interface{}) - if !ok || len(artifacts) != 1 { - t.Fatalf("expected one artifact, got %+v", resp.Payload["artifacts"]) - } - if artifacts[0]["name"] != "camera-test.mp4" { - t.Fatalf("unexpected artifact: %+v", artifacts[0]) - } -} - -func TestExecuteNodeRequestRunsLocalScreenRecord(t *testing.T) { - prevCfg := globalConfigPathOverride - prevExecutors := nodeLocalExecutors - prevRecord := nodeScreenRecordFunc - globalConfigPathOverride = filepath.Join(t.TempDir(), "config.json") - nodeLocalExecutors = map[string]*nodeLocalExecutor{} - defer func() { - globalConfigPathOverride = prevCfg - nodeLocalExecutors = prevExecutors - nodeScreenRecordFunc = prevRecord - }() - - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = filepath.Join(t.TempDir(), "workspace") - if err := config.SaveConfig(globalConfigPathOverride, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - nodeScreenRecordFunc = func(ctx context.Context, workspace string, args map[string]interface{}) (string, error) { - out := filepath.Join(workspace, "artifacts", "node", "screen-test.mp4") - if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil { - return "", err - } - if err := os.WriteFile(out, []byte("screen-video"), 0644); err != nil { - return "", err - } - return out, nil - } - - info := nodes.NodeInfo{ID: "edge-record", Name: "Edge Record"} - resp := executeNodeRequest(context.Background(), &http.Client{Timeout: time.Second}, info, nodeRegisterOptions{}, &nodes.Request{ - Action: "screen_record", - Args: map[string]interface{}{"duration_ms": 1800}, - }) - if !resp.OK { - t.Fatalf("expected ok response, got %+v", resp) - } - if got, _ := resp.Payload["duration_ms"].(int); got != 1800 { - t.Fatalf("unexpected duration payload: %+v", resp.Payload) - } - artifacts, ok := resp.Payload["artifacts"].([]map[string]interface{}) - if !ok || len(artifacts) != 1 { - t.Fatalf("expected one artifact, got %+v", resp.Payload["artifacts"]) - } - if artifacts[0]["name"] != "screen-test.mp4" { - t.Fatalf("unexpected artifact: %+v", artifacts[0]) - } -} diff --git a/cmd/cmd_status.go b/cmd/cmd_status.go index 8f6e04b..14cfc51 100644 --- a/cmd/cmd_status.go +++ b/cmd/cmd_status.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/nodes" "github.com/YspCoder/clawgo/pkg/providers" ) @@ -56,38 +55,6 @@ func statusCmd() { fmt.Printf("Log Max Size: %d MB\n", cfg.Logging.MaxSizeMB) fmt.Printf("Log Retention: %d days\n", cfg.Logging.RetentionDays) } - fmt.Printf("Nodes P2P: enabled=%t transport=%s\n", cfg.Gateway.Nodes.P2P.Enabled, strings.TrimSpace(cfg.Gateway.Nodes.P2P.Transport)) - fmt.Printf("Nodes P2P ICE: stun=%d ice=%d\n", len(cfg.Gateway.Nodes.P2P.STUNServers), len(cfg.Gateway.Nodes.P2P.ICEServers)) - ns := nodes.DefaultManager().List() - if len(ns) > 0 { - online := 0 - caps := map[string]int{"run": 0, "model": 0, "camera": 0, "screen": 0, "location": 0, "canvas": 0} - for _, n := range ns { - if n.Online { - online++ - } - if n.Capabilities.Run { - caps["run"]++ - } - if n.Capabilities.Model { - caps["model"]++ - } - if n.Capabilities.Camera { - caps["camera"]++ - } - if n.Capabilities.Screen { - caps["screen"]++ - } - if n.Capabilities.Location { - caps["location"]++ - } - if n.Capabilities.Canvas { - caps["canvas"]++ - } - } - fmt.Printf("Nodes: total=%d online=%d\n", len(ns), online) - fmt.Printf("Nodes Capabilities: run=%d model=%d camera=%d screen=%d location=%d canvas=%d\n", caps["run"], caps["model"], caps["camera"], caps["screen"], caps["location"], caps["canvas"]) - } } } diff --git a/cmd/cmd_status_test.go b/cmd/cmd_status_test.go index 412e7c0..4aae9e4 100644 --- a/cmd/cmd_status_test.go +++ b/cmd/cmd_status_test.go @@ -25,12 +25,6 @@ func TestStatusCmdUsesActiveProviderDetails(t *testing.T) { cfg.Logging.Enabled = false cfg.Agents.Defaults.Workspace = workspace cfg.Agents.Defaults.Model.Primary = "backup/backup-model" - cfg.Gateway.Nodes.P2P.Enabled = true - cfg.Gateway.Nodes.P2P.Transport = "webrtc" - cfg.Gateway.Nodes.P2P.STUNServers = []string{"stun:stun.example.net:3478"} - cfg.Gateway.Nodes.P2P.ICEServers = []config.GatewayICEConfig{ - {URLs: []string{"turn:turn.example.net:3478"}, Username: "user", Credential: "secret"}, - } cfg.Models.Providers["openai"] = config.ProviderConfig{ APIBase: "https://primary.example/v1", APIKey: "", @@ -79,10 +73,4 @@ func TestStatusCmdUsesActiveProviderDetails(t *testing.T) { if !strings.Contains(out, "API Key Status: configured") { t.Fatalf("expected active provider api key status in output, got: %s", out) } - if !strings.Contains(out, "Nodes P2P: enabled=true transport=webrtc") { - t.Fatalf("expected nodes p2p status in output, got: %s", out) - } - if !strings.Contains(out, "Nodes P2P ICE: stun=1 ice=1") { - t.Fatalf("expected nodes p2p ice summary in output, got: %s", out) - } } diff --git a/cmd/main.go b/cmd/main.go index 7724daf..6e4e429 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -67,8 +67,6 @@ func main() { cronCmd() case "channel": channelCmd() - case "node": - nodeCmd() case "skills": skillsCmd() case "tui": diff --git a/config.example.json b/config.example.json index 0bcc124..e2755c3 100644 --- a/config.example.json +++ b/config.example.json @@ -138,22 +138,6 @@ "retry_backoff_ms": 1000, "max_parallel_runs": 2 } - }, - "node.edge-dev.main": { - "enabled": true, - "type": "worker", - "transport": "node", - "node_id": "edge-dev", - "parent_agent_id": "main", - "notify_main_policy": "internal_only", - "display_name": "Edge Dev Main Agent", - "role": "remote_main", - "memory_namespace": "node.edge-dev.main", - "runtime": { - "max_retries": 1, - "retry_backoff_ms": 1000, - "max_parallel_runs": 1 - } } } }, @@ -387,21 +371,7 @@ "gateway": { "host": "0.0.0.0", "port": 18790, - "token": "", - "nodes": { - "p2p": { - "enabled": false, - "transport": "websocket_tunnel", - "stun_servers": [], - "ice_servers": [ - { - "urls": [ - "stun:stun.l.google.com:19302" - ] - } - ] - } - } + "token": "" }, "cron": { "min_sleep_sec": 1, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index cc0a088..f31eefc 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -27,7 +27,6 @@ import ( "github.com/YspCoder/clawgo/pkg/config" "github.com/YspCoder/clawgo/pkg/cron" "github.com/YspCoder/clawgo/pkg/logger" - "github.com/YspCoder/clawgo/pkg/nodes" "github.com/YspCoder/clawgo/pkg/providers" "github.com/YspCoder/clawgo/pkg/runtimecfg" "github.com/YspCoder/clawgo/pkg/scheduling" @@ -64,7 +63,6 @@ type AgentLoop struct { sessionStreamed map[string]bool subagentManager *tools.SubagentManager subagentRouter *tools.SubagentRouter - nodeRouter *nodes.Router configPath string subagentDigestMu sync.Mutex subagentDigestDelay time.Duration @@ -99,38 +97,6 @@ func (al *AgentLoop) SetConfigPath(path string) { al.configPath = strings.TrimSpace(path) } -func (al *AgentLoop) SetNodeP2PTransport(t nodes.Transport) { - if al == nil || al.nodeRouter == nil { - return - } - al.nodeRouter.P2P = t -} - -func (al *AgentLoop) DispatchNodeRequest(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error) { - if al == nil || al.tools == nil { - return nodes.Response{}, fmt.Errorf("agent loop not ready") - } - args := map[string]interface{}{ - "action": req.Action, - "node": req.Node, - "mode": mode, - "task": req.Task, - "model": req.Model, - } - if len(req.Args) > 0 { - args["args"] = req.Args - } - out, err := al.tools.Execute(ctx, "nodes", args) - if err != nil { - return nodes.Response{}, err - } - var resp nodes.Response - if err := json.Unmarshal([]byte(out), &resp); err != nil { - return nodes.Response{}, err - } - return resp, nil -} - // StartupCompactionReport provides startup memory/session maintenance stats. type StartupCompactionReport struct { TotalSessions int `json:"total_sessions"` @@ -154,66 +120,6 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers toolsRegistry.Register(tools.NewExecTool(cfg.Tools.Shell, workspace, processManager)) toolsRegistry.Register(tools.NewProcessTool(processManager)) toolsRegistry.Register(tools.NewSkillExecTool(workspace)) - nodesManager := nodes.DefaultManager() - nodesManager.SetAuditPath(filepath.Join(workspace, "memory", "nodes-audit.jsonl")) - nodesManager.SetStatePath(filepath.Join(workspace, "memory", "nodes-state.json")) - nodesManager.Upsert(nodes.NodeInfo{ID: "local", Name: "local", Capabilities: nodes.Capabilities{Run: true, Invoke: true, Model: true, Camera: true, Screen: true, Location: true, Canvas: true}, Models: []string{"local-sim"}, Online: true}) - nodesManager.RegisterHandler("local", func(req nodes.Request) nodes.Response { - switch req.Action { - case "run": - payload := map[string]interface{}{"transport": "relay-local", "simulated": true} - if cmdRaw, ok := req.Args["command"].([]interface{}); ok && len(cmdRaw) > 0 { - parts := make([]string, 0, len(cmdRaw)) - for _, x := range cmdRaw { - parts = append(parts, fmt.Sprint(x)) - } - payload["command"] = parts - } - return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: payload} - case "agent_task": - return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "simulated": true, "model": req.Model, "task": req.Task, "result": "local child-model simulated execution completed"}} - case "camera_snap": - return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "media_type": "image", "storage": "inline", "facing": req.Args["facing"], "simulated": true, "meta": map[string]interface{}{"width": 1280, "height": 720}}} - case "camera_clip": - return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "media_type": "video", "storage": "path", "path": "/tmp/camera_clip.mp4", "duration_ms": req.Args["duration_ms"], "simulated": true, "meta": map[string]interface{}{"fps": 30}}} - case "screen_snapshot": - return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "media_type": "image", "storage": "inline", "simulated": true, "meta": map[string]interface{}{"width": 1920, "height": 1080}}} - case "screen_record": - return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "media_type": "video", "storage": "path", "path": "/tmp/screen_record.mp4", "duration_ms": req.Args["duration_ms"], "simulated": true, "meta": map[string]interface{}{"fps": 30}}} - case "location_get": - return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "lat": 0.0, "lng": 0.0, "accuracy": "simulated", "meta": map[string]interface{}{"provider": "simulated"}}} - case "canvas_snapshot": - return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "image": "data:image/png;base64,", "media_type": "image", "storage": "inline", "simulated": true, "meta": map[string]interface{}{"width": 1280, "height": 720}}} - case "canvas_action": - return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "applied": true, "simulated": true, "args": req.Args}} - default: - return nodes.Response{OK: false, Code: "unsupported_action", Node: "local", Action: req.Action, Error: "unsupported local simulated action"} - } - }) - nodeDispatchPolicy := nodes.DispatchPolicy{ - PreferLocal: cfg.Gateway.Nodes.Dispatch.PreferLocal, - PreferP2P: cfg.Gateway.Nodes.Dispatch.PreferP2P, - AllowRelayFallback: cfg.Gateway.Nodes.Dispatch.AllowRelayFallback, - ActionTags: cfg.Gateway.Nodes.Dispatch.ActionTags, - AgentTags: cfg.Gateway.Nodes.Dispatch.AgentTags, - AllowActions: cfg.Gateway.Nodes.Dispatch.AllowActions, - DenyActions: cfg.Gateway.Nodes.Dispatch.DenyActions, - AllowAgents: cfg.Gateway.Nodes.Dispatch.AllowAgents, - DenyAgents: cfg.Gateway.Nodes.Dispatch.DenyAgents, - } - nodesManager.SetDispatchPolicy(nodeDispatchPolicy) - var nodeP2P nodes.Transport - if cfg.Gateway.Nodes.P2P.Enabled { - switch strings.ToLower(strings.TrimSpace(cfg.Gateway.Nodes.P2P.Transport)) { - case "", "websocket_tunnel": - nodeP2P = &nodes.WebsocketP2PTransport{Manager: nodesManager} - case "webrtc": - // Keep the mode explicit but non-default until a direct data channel is production-ready. - nodeP2P = &nodes.WebsocketP2PTransport{Manager: nodesManager} - } - } - nodesRouter := &nodes.Router{P2P: nodeP2P, Relay: &nodes.HTTPRelayTransport{Manager: nodesManager}, Policy: nodesManager.DispatchPolicy()} - toolsRegistry.Register(tools.NewNodesTool(nodesManager, nodesRouter, filepath.Join(workspace, "memory", "nodes-dispatch-audit.jsonl"))) if cs != nil { toolsRegistry.Register(tools.NewRemindTool(cs)) @@ -331,7 +237,6 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers telegramStreaming: cfg.Channels.Telegram.Streaming, subagentManager: subagentManager, subagentRouter: subagentRouter, - nodeRouter: nodesRouter, subagentDigestDelay: 5 * time.Second, subagentDigests: map[string]*subagentDigestState{}, } @@ -415,9 +320,6 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers if run == nil { return "", fmt.Errorf("subagent run is nil") } - if strings.EqualFold(strings.TrimSpace(run.Transport), "node") { - return loop.dispatchNodeSubagentRun(ctx, run) - } sessionKey := strings.TrimSpace(run.SessionKey) if sessionKey == "" { sessionKey = fmt.Sprintf("subagent:%s", strings.TrimSpace(run.ID)) @@ -453,68 +355,17 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers return loop } -func (al *AgentLoop) dispatchNodeSubagentRun(ctx context.Context, run *tools.SubagentRun) (string, error) { - if al == nil || run == nil { - return "", fmt.Errorf("node subagent run is nil") - } - if al.nodeRouter == nil { - return "", fmt.Errorf("node router is not configured") - } - nodeID := strings.TrimSpace(run.NodeID) - if nodeID == "" { - return "", fmt.Errorf("node-backed subagent %q missing node_id", run.AgentID) - } - taskInput := loopRunInputForNode(run) - reqArgs := map[string]interface{}{} - if remoteAgentID := remoteAgentIDForNodeBranch(run.AgentID, nodeID); remoteAgentID != "" { - reqArgs["remote_agent_id"] = remoteAgentID - } - resp, err := al.nodeRouter.Dispatch(ctx, nodes.Request{ - Action: "agent_task", - Node: nodeID, - Task: taskInput, - Args: reqArgs, - }, "auto") - if err != nil { - return "", err - } - if !resp.OK { - if strings.TrimSpace(resp.Error) != "" { - return "", fmt.Errorf("node %s agent_task failed: %s", nodeID, strings.TrimSpace(resp.Error)) - } - return "", fmt.Errorf("node %s agent_task failed", nodeID) - } - if result := nodeAgentTaskResult(resp.Payload); result != "" { - return result, nil - } - return fmt.Sprintf("node %s completed agent_task", nodeID), nil -} - -func remoteAgentIDForNodeBranch(agentID, nodeID string) string { - agentID = strings.TrimSpace(agentID) - nodeID = strings.TrimSpace(nodeID) - if agentID == "" || nodeID == "" { - return "" - } - prefix := "node." + nodeID + "." - if !strings.HasPrefix(agentID, prefix) { - return "" - } - remote := strings.TrimPrefix(agentID, prefix) - if strings.TrimSpace(remote) == "" { - return "" - } - return remote -} - -func loopRunInputForNode(run *tools.SubagentRun) string { +func (al *AgentLoop) buildSubagentRunInput(run *tools.SubagentRun) string { if run == nil { return "" } - if parent := strings.TrimSpace(run.ParentAgentID); parent != "" { - return fmt.Sprintf("Parent Agent: %s\nSubtree Branch: %s\n\nTask:\n%s", parent, run.AgentID, strings.TrimSpace(run.Task)) + taskText := strings.TrimSpace(run.Task) + if promptFile := strings.TrimSpace(run.SystemPromptFile); promptFile != "" { + if promptText := al.readSubagentPromptFile(promptFile); promptText != "" { + return fmt.Sprintf("Role Profile Policy (%s):\n%s\n\nTask:\n%s", promptFile, promptText, taskText) + } } - return strings.TrimSpace(run.Task) + return taskText } func nodeAgentTaskResult(payload map[string]interface{}) string { @@ -530,19 +381,6 @@ func nodeAgentTaskResult(payload map[string]interface{}) string { return "" } -func (al *AgentLoop) buildSubagentRunInput(run *tools.SubagentRun) string { - if run == nil { - return "" - } - taskText := strings.TrimSpace(run.Task) - if promptFile := strings.TrimSpace(run.SystemPromptFile); promptFile != "" { - if promptText := al.readSubagentPromptFile(promptFile); promptText != "" { - return fmt.Sprintf("Role Profile Policy (%s):\n%s\n\nTask:\n%s", promptFile, promptText, taskText) - } - } - return taskText -} - func (al *AgentLoop) readSubagentPromptFile(relPath string) string { if al == nil { return "" diff --git a/pkg/agent/loop_nodes_p2p_test.go b/pkg/agent/loop_nodes_p2p_test.go deleted file mode 100644 index 2d11472..0000000 --- a/pkg/agent/loop_nodes_p2p_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package agent - -import ( - "testing" - - "github.com/YspCoder/clawgo/pkg/bus" - "github.com/YspCoder/clawgo/pkg/config" -) - -func TestNewAgentLoopDisablesNodeP2PByDefault(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = t.TempDir() - - loop := NewAgentLoop(cfg, bus.NewMessageBus(), stubLLMProvider{}, nil) - if loop.nodeRouter == nil { - t.Fatalf("expected node router to be configured") - } - if loop.nodeRouter.P2P != nil { - t.Fatalf("expected node p2p transport to be disabled by default") - } -} - -func TestNewAgentLoopEnablesNodeP2PWhenConfigured(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Workspace = t.TempDir() - cfg.Gateway.Nodes.P2P.Enabled = true - - loop := NewAgentLoop(cfg, bus.NewMessageBus(), stubLLMProvider{}, nil) - if loop.nodeRouter == nil || loop.nodeRouter.P2P == nil { - t.Fatalf("expected node p2p transport to be enabled") - } -} diff --git a/pkg/agent/subagent_node_test.go b/pkg/agent/subagent_node_test.go deleted file mode 100644 index 845a65c..0000000 --- a/pkg/agent/subagent_node_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package agent - -import ( - "context" - "strings" - "testing" - - "github.com/YspCoder/clawgo/pkg/nodes" - "github.com/YspCoder/clawgo/pkg/tools" -) - -func TestDispatchNodeSubagentRunUsesNodeAgentTask(t *testing.T) { - manager := nodes.NewManager() - manager.Upsert(nodes.NodeInfo{ - ID: "edge-dev", - Name: "Edge Dev", - Online: true, - Capabilities: nodes.Capabilities{ - Model: true, - }, - }) - manager.RegisterHandler("edge-dev", func(req nodes.Request) nodes.Response { - if req.Action != "agent_task" { - t.Fatalf("unexpected action: %s", req.Action) - } - if got, _ := req.Args["remote_agent_id"].(string); got != "coder" { - t.Fatalf("expected remote_agent_id=coder, got %+v", req.Args) - } - if !strings.Contains(req.Task, "Parent Agent: main") { - t.Fatalf("expected parent-agent context in task, got %q", req.Task) - } - return nodes.Response{ - OK: true, - Action: req.Action, - Node: req.Node, - Payload: map[string]interface{}{ - "result": "remote-main-done", - }, - } - }) - - loop := &AgentLoop{ - nodeRouter: &nodes.Router{ - Relay: &nodes.HTTPRelayTransport{Manager: manager}, - }, - } - out, err := loop.dispatchNodeSubagentRun(context.Background(), &tools.SubagentRun{ - ID: "subagent-1", - AgentID: "node.edge-dev.coder", - Transport: "node", - NodeID: "edge-dev", - ParentAgentID: "main", - Task: "Implement fix on remote node", - }) - if err != nil { - t.Fatalf("dispatchNodeSubagentRun failed: %v", err) - } - if out != "remote-main-done" { - t.Fatalf("unexpected node result: %q", out) - } -} diff --git a/pkg/api/server.go b/pkg/api/server.go index 63399a6..b745ab5 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -4,10 +4,8 @@ import ( "archive/tar" "archive/zip" "bufio" - "bytes" "compress/gzip" "context" - "crypto/sha1" "encoding/base64" "encoding/json" "errors" @@ -30,48 +28,38 @@ import ( "github.com/YspCoder/clawgo/pkg/channels" cfgpkg "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/nodes" "github.com/YspCoder/clawgo/pkg/providers" "github.com/YspCoder/clawgo/pkg/tools" "github.com/gorilla/websocket" "rsc.io/qr" ) -type Server struct { - addr string - token string - mgr *nodes.Manager - server *http.Server - nodeConnMu sync.Mutex - nodeConnIDs map[string]string - nodeSockets map[string]*nodeSocketConn - nodeWebRTC *nodes.WebRTCTransport - nodeP2PStatus func() map[string]interface{} - artifactStatsMu sync.Mutex - artifactStats map[string]interface{} - gatewayVersion string - configPath string - workspacePath string - logFilePath string - onChat func(ctx context.Context, sessionKey, content string) (string, error) - onChatHistory func(sessionKey string) []map[string]interface{} - onConfigAfter func() error - onCron func(action string, args map[string]interface{}) (interface{}, error) - onNodeDispatch func(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error) - onToolsCatalog func() interface{} - whatsAppBridge *channels.WhatsAppBridgeService - whatsAppBase string - oauthFlowMu sync.Mutex - oauthFlows map[string]*providers.OAuthPendingFlow - extraRoutesMu sync.RWMutex - extraRoutes map[string]http.Handler -} - -var nodesWebsocketUpgrader = websocket.Upgrader{ +var websocketUpgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } -func NewServer(host string, port int, token string, mgr *nodes.Manager) *Server { +type Server struct { + addr string + token string + server *http.Server + gatewayVersion string + configPath string + workspacePath string + logFilePath string + onChat func(ctx context.Context, sessionKey, content string) (string, error) + onChatHistory func(sessionKey string) []map[string]interface{} + onConfigAfter func() error + onCron func(action string, args map[string]interface{}) (interface{}, error) + onToolsCatalog func() interface{} + whatsAppBridge *channels.WhatsAppBridgeService + whatsAppBase string + oauthFlowMu sync.Mutex + oauthFlows map[string]*providers.OAuthPendingFlow + extraRoutesMu sync.RWMutex + extraRoutes map[string]http.Handler +} + +func NewServer(host string, port int, token string) *Server { addr := strings.TrimSpace(host) if addr == "" { addr = "0.0.0.0" @@ -80,33 +68,13 @@ func NewServer(host string, port int, token string, mgr *nodes.Manager) *Server port = 7788 } return &Server{ - addr: fmt.Sprintf("%s:%d", addr, port), - token: strings.TrimSpace(token), - mgr: mgr, - nodeConnIDs: map[string]string{}, - nodeSockets: map[string]*nodeSocketConn{}, - artifactStats: map[string]interface{}{}, - oauthFlows: map[string]*providers.OAuthPendingFlow{}, - extraRoutes: map[string]http.Handler{}, + addr: fmt.Sprintf("%s:%d", addr, port), + token: strings.TrimSpace(token), + oauthFlows: map[string]*providers.OAuthPendingFlow{}, + extraRoutes: map[string]http.Handler{}, } } -type nodeSocketConn struct { - connID string - conn *websocket.Conn - mu sync.Mutex -} - -func (c *nodeSocketConn) Send(msg nodes.WireMessage) error { - if c == nil || c.conn == nil { - return fmt.Errorf("node websocket unavailable") - } - c.mu.Lock() - defer c.mu.Unlock() - _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - return c.conn.WriteJSON(msg) -} - func (s *Server) SetConfigPath(path string) { s.configPath = strings.TrimSpace(path) } func (s *Server) SetWorkspacePath(path string) { s.workspacePath = strings.TrimSpace(path) } func (s *Server) SetLogFilePath(path string) { s.logFilePath = strings.TrimSpace(path) } @@ -121,9 +89,6 @@ func (s *Server) SetConfigAfterHook(fn func() error) { s.onConfigAfter = fn } func (s *Server) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) { s.onCron = fn } -func (s *Server) SetNodeDispatchHandler(fn func(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error)) { - s.onNodeDispatch = fn -} func (s *Server) SetToolsCatalogHandler(fn func() interface{}) { s.onToolsCatalog = fn } func (s *Server) SetGatewayVersion(v string) { s.gatewayVersion = strings.TrimSpace(v) } func (s *Server) SetProtectedRoute(path string, handler http.Handler) { @@ -139,12 +104,6 @@ func (s *Server) SetProtectedRoute(path string, handler http.Handler) { } s.extraRoutes[path] = handler } -func (s *Server) SetNodeWebRTCTransport(t *nodes.WebRTCTransport) { - s.nodeWebRTC = t -} -func (s *Server) SetNodeP2PStatusHandler(fn func() map[string]interface{}) { - s.nodeP2PStatus = fn -} func (s *Server) SetWhatsAppBridge(service *channels.WhatsAppBridgeService, basePath string) { s.whatsAppBridge = service s.whatsAppBase = strings.TrimSpace(basePath) @@ -211,96 +170,12 @@ func queryBoundedPositiveInt(r *http.Request, key string, fallback int, max int) return n } -func (s *Server) rememberNodeConnection(nodeID, connID string) { - nodeID = strings.TrimSpace(nodeID) - connID = strings.TrimSpace(connID) - if nodeID == "" || connID == "" { - return - } - s.nodeConnMu.Lock() - defer s.nodeConnMu.Unlock() - s.nodeConnIDs[nodeID] = connID -} - -func (s *Server) bindNodeSocket(nodeID, connID string, conn *websocket.Conn) { - nodeID = strings.TrimSpace(nodeID) - connID = strings.TrimSpace(connID) - if nodeID == "" || connID == "" || conn == nil { - return - } - next := &nodeSocketConn{connID: connID, conn: conn} - s.nodeConnMu.Lock() - prev := s.nodeSockets[nodeID] - s.nodeSockets[nodeID] = next - s.nodeConnMu.Unlock() - if s.mgr != nil { - s.mgr.RegisterWireSender(nodeID, next) - } - if s.nodeWebRTC != nil { - s.nodeWebRTC.BindSignaler(nodeID, next) - } - if prev != nil && prev.connID != connID { - _ = prev.conn.Close() - } -} - -func (s *Server) releaseNodeConnection(nodeID, connID string) bool { - nodeID = strings.TrimSpace(nodeID) - connID = strings.TrimSpace(connID) - if nodeID == "" || connID == "" { - return false - } - s.nodeConnMu.Lock() - defer s.nodeConnMu.Unlock() - if s.nodeConnIDs[nodeID] != connID { - return false - } - delete(s.nodeConnIDs, nodeID) - if sock := s.nodeSockets[nodeID]; sock != nil && sock.connID == connID { - delete(s.nodeSockets, nodeID) - } - if s.mgr != nil { - s.mgr.RegisterWireSender(nodeID, nil) - } - if s.nodeWebRTC != nil { - s.nodeWebRTC.UnbindSignaler(nodeID) - } - return true -} - -func (s *Server) getNodeSocket(nodeID string) *nodeSocketConn { - nodeID = strings.TrimSpace(nodeID) - if nodeID == "" { - return nil - } - s.nodeConnMu.Lock() - defer s.nodeConnMu.Unlock() - return s.nodeSockets[nodeID] -} - -func (s *Server) sendNodeSocketMessage(nodeID string, msg nodes.WireMessage) error { - sock := s.getNodeSocket(nodeID) - if sock == nil || sock.conn == nil { - return fmt.Errorf("node %s not connected", strings.TrimSpace(nodeID)) - } - sock.mu.Lock() - defer sock.mu.Unlock() - _ = sock.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - return sock.conn.WriteJSON(msg) -} - func (s *Server) Start(ctx context.Context) error { - if s.mgr == nil { - return nil - } mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) - mux.HandleFunc("/nodes/register", s.handleRegister) - mux.HandleFunc("/nodes/heartbeat", s.handleHeartbeat) - mux.HandleFunc("/nodes/connect", s.handleNodeConnect) mux.HandleFunc("/api/config", s.handleWebUIConfig) mux.HandleFunc("/api/chat", s.handleWebUIChat) mux.HandleFunc("/api/chat/history", s.handleWebUIChatHistory) @@ -316,14 +191,6 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("/api/whatsapp/logout", s.handleWebUIWhatsAppLogout) mux.HandleFunc("/api/whatsapp/qr.svg", s.handleWebUIWhatsAppQR) mux.HandleFunc("/api/upload", s.handleWebUIUpload) - mux.HandleFunc("/api/nodes", s.handleWebUINodes) - mux.HandleFunc("/api/node_dispatches", s.handleWebUINodeDispatches) - mux.HandleFunc("/api/node_dispatches/replay", s.handleWebUINodeDispatchReplay) - mux.HandleFunc("/api/node_artifacts", s.handleWebUINodeArtifacts) - mux.HandleFunc("/api/node_artifacts/export", s.handleWebUINodeArtifactsExport) - mux.HandleFunc("/api/node_artifacts/download", s.handleWebUINodeArtifactDownload) - mux.HandleFunc("/api/node_artifacts/delete", s.handleWebUINodeArtifactDelete) - mux.HandleFunc("/api/node_artifacts/prune", s.handleWebUINodeArtifactPrune) mux.HandleFunc("/api/cron", s.handleWebUICron) mux.HandleFunc("/api/skills", s.handleWebUISkills) mux.HandleFunc("/api/sessions", s.handleWebUISessions) @@ -383,178 +250,6 @@ func (s *Server) withCORS(next http.Handler) http.Handler { }) } -func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - var n nodes.NodeInfo - if err := json.NewDecoder(r.Body).Decode(&n); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - if strings.TrimSpace(n.ID) == "" { - http.Error(w, "id required", http.StatusBadRequest) - return - } - s.mgr.Upsert(n) - writeJSON(w, map[string]interface{}{"ok": true, "id": n.ID}) -} - -func (s *Server) handleHeartbeat(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - var body struct { - ID string `json:"id"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == "" { - http.Error(w, "id required", http.StatusBadRequest) - return - } - n, ok := s.mgr.Get(body.ID) - if !ok { - http.Error(w, "node not found", http.StatusNotFound) - return - } - n.LastSeenAt = time.Now().UTC() - n.Online = true - s.mgr.Upsert(n) - writeJSON(w, map[string]interface{}{"ok": true, "id": body.ID}) -} - -func (s *Server) handleNodeConnect(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if s.mgr == nil { - http.Error(w, "nodes manager unavailable", http.StatusInternalServerError) - return - } - conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer conn.Close() - - var connectedID string - connID := fmt.Sprintf("%d", time.Now().UnixNano()) - _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second)) - conn.SetPongHandler(func(string) error { - return conn.SetReadDeadline(time.Now().Add(90 * time.Second)) - }) - - writeAck := func(ack nodes.WireAck) error { - _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - return conn.WriteJSON(ack) - } - - defer func() { - if strings.TrimSpace(connectedID) != "" && s.releaseNodeConnection(connectedID, connID) { - s.mgr.MarkOffline(connectedID) - } - }() - - for { - var msg nodes.WireMessage - if err := conn.ReadJSON(&msg); err != nil { - return - } - _ = conn.SetReadDeadline(time.Now().Add(90 * time.Second)) - if s.mgr != nil && s.mgr.HandleWireMessage(msg) { - continue - } - switch strings.ToLower(strings.TrimSpace(msg.Type)) { - case "register": - if msg.Node == nil || strings.TrimSpace(msg.Node.ID) == "" { - _ = writeAck(nodes.WireAck{OK: false, Type: "register", Error: "node.id required"}) - continue - } - s.mgr.Upsert(*msg.Node) - connectedID = strings.TrimSpace(msg.Node.ID) - s.rememberNodeConnection(connectedID, connID) - s.bindNodeSocket(connectedID, connID, conn) - if err := writeAck(nodes.WireAck{OK: true, Type: "registered", ID: connectedID}); err != nil { - return - } - case "heartbeat": - id := strings.TrimSpace(msg.ID) - if id == "" { - id = connectedID - } - if id == "" { - _ = writeAck(nodes.WireAck{OK: false, Type: "heartbeat", Error: "id required"}) - continue - } - if msg.Node != nil && strings.TrimSpace(msg.Node.ID) != "" { - s.mgr.Upsert(*msg.Node) - connectedID = strings.TrimSpace(msg.Node.ID) - s.rememberNodeConnection(connectedID, connID) - s.bindNodeSocket(connectedID, connID, conn) - } else if n, ok := s.mgr.Get(id); ok { - s.mgr.Upsert(n) - connectedID = id - s.rememberNodeConnection(connectedID, connID) - s.bindNodeSocket(connectedID, connID, conn) - } else { - _ = writeAck(nodes.WireAck{OK: false, Type: "heartbeat", ID: id, Error: "node not found"}) - continue - } - if err := writeAck(nodes.WireAck{OK: true, Type: "heartbeat", ID: connectedID}); err != nil { - return - } - case "signal_offer", "signal_answer", "signal_candidate": - targetID := strings.TrimSpace(msg.To) - if s.nodeWebRTC != nil && (targetID == "" || strings.EqualFold(targetID, "gateway")) { - if err := s.nodeWebRTC.HandleSignal(msg); err != nil { - if err := writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: err.Error()}); err != nil { - return - } - } else if err := writeAck(nodes.WireAck{OK: true, Type: "signaled", ID: msg.ID}); err != nil { - return - } - continue - } - if strings.TrimSpace(connectedID) == "" { - if err := writeAck(nodes.WireAck{OK: false, Type: msg.Type, Error: "node not registered"}); err != nil { - return - } - continue - } - if targetID == "" { - if err := writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: "target node required"}); err != nil { - return - } - continue - } - msg.From = connectedID - if err := s.sendNodeSocketMessage(targetID, msg); err != nil { - if err := writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: err.Error()}); err != nil { - return - } - continue - } - if err := writeAck(nodes.WireAck{OK: true, Type: "relayed", ID: msg.ID}); err != nil { - return - } - default: - if err := writeAck(nodes.WireAck{OK: false, Type: msg.Type, ID: msg.ID, Error: "unsupported message type"}); err != nil { - return - } - } - } -} - func (s *Server) handleWebUIConfig(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -1308,7 +1003,7 @@ func (s *Server) handleWebUIChatLive(w http.ResponseWriter, r *http.Request) { http.Error(w, "chat handler not configured", http.StatusInternalServerError) return } - conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil) + conn, err := websocketUpgrader.Upgrade(w, r, nil) if err != nil { return } @@ -1626,307 +1321,6 @@ func renderQRCodeSVG(code *qr.Code, scale, quietZone int) string { return b.String() } -func (s *Server) webUINodesPayload(ctx context.Context) map[string]interface{} { - list := []nodes.NodeInfo{} - if s.mgr != nil { - list = s.mgr.List() - } - localRegistry := s.fetchRegistryItems(ctx) - localAgents := make([]nodes.AgentInfo, 0, len(localRegistry)) - for _, item := range localRegistry { - agentID := strings.TrimSpace(stringFromMap(item, "agent_id")) - if agentID == "" { - continue - } - localAgents = append(localAgents, nodes.AgentInfo{ - ID: agentID, - DisplayName: strings.TrimSpace(stringFromMap(item, "display_name")), - Role: strings.TrimSpace(stringFromMap(item, "role")), - Type: strings.TrimSpace(stringFromMap(item, "type")), - Transport: fallbackString(strings.TrimSpace(stringFromMap(item, "transport")), "local"), - }) - } - host, _ := os.Hostname() - local := nodes.NodeInfo{ - ID: "local", - Name: "local", - Endpoint: "gateway", - Version: gatewayBuildVersion(), - OS: runtime.GOOS, - Arch: runtime.GOARCH, - LastSeenAt: time.Now(), - Online: true, - Capabilities: nodes.Capabilities{Run: true, Invoke: true, Model: true, Camera: true, Screen: true, Location: true, Canvas: true}, - Actions: []string{"run", "agent_task", "camera_snap", "camera_clip", "screen_snapshot", "screen_record", "location_get", "canvas_snapshot", "canvas_action"}, - Models: []string{"local-sim"}, - Agents: localAgents, - } - if strings.TrimSpace(host) != "" { - local.Name = host - } - if ip := detectLocalIP(); ip != "" { - local.Endpoint = ip - } - hostLower := strings.ToLower(strings.TrimSpace(host)) - matched := false - for i := range list { - id := strings.ToLower(strings.TrimSpace(list[i].ID)) - name := strings.ToLower(strings.TrimSpace(list[i].Name)) - if id == "local" || name == "local" || (hostLower != "" && name == hostLower) { - list[i].ID = "local" - list[i].Online = true - list[i].Version = local.Version - if strings.TrimSpace(local.Endpoint) != "" { - list[i].Endpoint = local.Endpoint - } - if strings.TrimSpace(local.Name) != "" { - list[i].Name = local.Name - } - list[i].LastSeenAt = time.Now() - matched = true - break - } - } - if !matched { - list = append([]nodes.NodeInfo{local}, list...) - } - p2p := map[string]interface{}{} - if s.nodeP2PStatus != nil { - p2p = s.nodeP2PStatus() - } - dispatches := s.webUINodesDispatchPayload(12) - return map[string]interface{}{ - "nodes": list, - "trees": s.buildNodeAgentTrees(ctx, list), - "p2p": p2p, - "dispatches": dispatches, - "alerts": s.webUINodeAlertsPayload(list, p2p, dispatches), - "artifact_retention": s.artifactStatsSnapshot(), - } -} - -func (s *Server) webUINodeAlertsPayload(nodeList []nodes.NodeInfo, p2p map[string]interface{}, dispatches []map[string]interface{}) []map[string]interface{} { - alerts := make([]map[string]interface{}, 0) - for _, node := range nodeList { - nodeID := strings.TrimSpace(node.ID) - if nodeID == "" || nodeID == "local" { - continue - } - if !node.Online { - alerts = append(alerts, map[string]interface{}{ - "severity": "critical", - "kind": "node_offline", - "node": nodeID, - "title": "Node offline", - "detail": fmt.Sprintf("node %s is offline", nodeID), - }) - } - } - if sessions, ok := p2p["nodes"].([]map[string]interface{}); ok { - for _, session := range sessions { - appendNodeSessionAlert(&alerts, session) - } - } else if sessions, ok := p2p["nodes"].([]interface{}); ok { - for _, raw := range sessions { - if session, ok := raw.(map[string]interface{}); ok { - appendNodeSessionAlert(&alerts, session) - } - } - } - failuresByNode := map[string]int{} - for _, row := range dispatches { - nodeID := strings.TrimSpace(fmt.Sprint(row["node"])) - if nodeID == "" { - continue - } - if ok, _ := tools.MapBoolArg(row, "ok"); ok { - continue - } - failuresByNode[nodeID]++ - } - for nodeID, count := range failuresByNode { - if count < 2 { - continue - } - alerts = append(alerts, map[string]interface{}{ - "severity": "warning", - "kind": "dispatch_failures", - "node": nodeID, - "title": "Repeated dispatch failures", - "detail": fmt.Sprintf("node %s has %d recent failed dispatches", nodeID, count), - "count": count, - }) - } - return alerts -} - -func appendNodeSessionAlert(alerts *[]map[string]interface{}, session map[string]interface{}) { - nodeID := strings.TrimSpace(fmt.Sprint(session["node"])) - if nodeID == "" { - return - } - status := strings.ToLower(strings.TrimSpace(fmt.Sprint(session["status"]))) - retryCount := int(int64Value(session["retry_count"])) - lastError := strings.TrimSpace(fmt.Sprint(session["last_error"])) - switch { - case status == "failed" || status == "closed": - *alerts = append(*alerts, map[string]interface{}{ - "severity": "critical", - "kind": "p2p_session_down", - "node": nodeID, - "title": "P2P session down", - "detail": firstNonEmptyString(lastError, fmt.Sprintf("node %s p2p session is %s", nodeID, status)), - }) - case retryCount >= 3 || (status == "connecting" && retryCount >= 2): - *alerts = append(*alerts, map[string]interface{}{ - "severity": "warning", - "kind": "p2p_session_unstable", - "node": nodeID, - "title": "P2P session unstable", - "detail": firstNonEmptyString(lastError, fmt.Sprintf("node %s p2p session retry_count=%d", nodeID, retryCount)), - "count": retryCount, - }) - } -} - -func int64Value(v interface{}) int64 { - switch value := v.(type) { - case int: - return int64(value) - case int32: - return int64(value) - case int64: - return value - case float32: - return int64(value) - case float64: - return int64(value) - case json.Number: - if n, err := value.Int64(); err == nil { - return n - } - } - return 0 -} - -func (s *Server) webUINodesDispatchPayload(limit int) []map[string]interface{} { - path := s.memoryFilePath("nodes-dispatch-audit.jsonl") - if path == "" { - return []map[string]interface{}{} - } - data, err := os.ReadFile(path) - if err != nil { - return []map[string]interface{}{} - } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(lines) == 1 && strings.TrimSpace(lines[0]) == "" { - return []map[string]interface{}{} - } - out := make([]map[string]interface{}, 0, limit) - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line == "" { - continue - } - row := map[string]interface{}{} - if err := json.Unmarshal([]byte(line), &row); err != nil { - continue - } - out = append(out, row) - if limit > 0 && len(out) >= limit { - break - } - } - return out -} - -func (s *Server) webUINodeArtifactsPayload(limit int) []map[string]interface{} { - return s.webUINodeArtifactsPayloadFiltered("", "", "", limit) -} - -func (s *Server) webUINodeArtifactsPayloadFiltered(nodeFilter, actionFilter, kindFilter string, limit int) []map[string]interface{} { - nodeFilter = strings.TrimSpace(nodeFilter) - actionFilter = strings.TrimSpace(actionFilter) - kindFilter = strings.TrimSpace(kindFilter) - rows, _ := s.readNodeDispatchAuditRows() - if len(rows) == 0 { - return []map[string]interface{}{} - } - out := make([]map[string]interface{}, 0, limit) - for rowIndex := len(rows) - 1; rowIndex >= 0; rowIndex-- { - row := rows[rowIndex] - artifacts, _ := row["artifacts"].([]interface{}) - for artifactIndex, raw := range artifacts { - artifact, ok := raw.(map[string]interface{}) - if !ok { - continue - } - item := map[string]interface{}{ - "id": buildNodeArtifactID(row, artifact, artifactIndex), - "time": row["time"], - "node": row["node"], - "action": row["action"], - "used_transport": row["used_transport"], - "ok": row["ok"], - "error": row["error"], - } - for _, key := range []string{"name", "kind", "mime_type", "storage", "path", "url", "content_text", "content_base64", "source_path", "size_bytes"} { - if value, ok := artifact[key]; ok { - item[key] = value - } - } - if nodeFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["node"])), nodeFilter) { - continue - } - if actionFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["action"])), actionFilter) { - continue - } - if kindFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["kind"])), kindFilter) { - continue - } - out = append(out, item) - if limit > 0 && len(out) >= limit { - return out - } - } - } - return out -} - -func (s *Server) readNodeDispatchAuditRows() ([]map[string]interface{}, string) { - path := s.memoryFilePath("nodes-dispatch-audit.jsonl") - if path == "" { - return nil, "" - } - data, err := os.ReadFile(path) - if err != nil { - return nil, path - } - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - rows := make([]map[string]interface{}, 0, len(lines)) - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - row := map[string]interface{}{} - if err := json.Unmarshal([]byte(line), &row); err != nil { - continue - } - rows = append(rows, row) - } - return rows, path -} - -func buildNodeArtifactID(row, artifact map[string]interface{}, artifactIndex int) string { - seed := fmt.Sprintf("%v|%v|%v|%d|%v|%v|%v", - row["time"], row["node"], row["action"], artifactIndex, - artifact["name"], artifact["source_path"], artifact["path"], - ) - sum := sha1.Sum([]byte(seed)) - return fmt.Sprintf("%x", sum[:8]) -} - func sanitizeZipEntryName(name string) string { name = strings.TrimSpace(name) if name == "" { @@ -1954,15 +1348,6 @@ func sanitizeZipEntryName(name string) string { return name } -func (s *Server) findNodeArtifactByID(id string) (map[string]interface{}, bool) { - for _, item := range s.webUINodeArtifactsPayload(10000) { - if strings.TrimSpace(fmt.Sprint(item["id"])) == id { - return item, true - } - } - return nil, false -} - func resolveArtifactPath(workspace, raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -2080,209 +1465,6 @@ func (s *Server) memoryFilePath(name string) string { } return filepath.Join(workspace, "memory", strings.TrimSpace(name)) } - -func (s *Server) filteredNodeDispatches(nodeFilter, actionFilter string, limit int) []map[string]interface{} { - items := s.webUINodesDispatchPayload(limit) - if nodeFilter == "" && actionFilter == "" { - return items - } - out := make([]map[string]interface{}, 0, len(items)) - for _, item := range items { - if nodeFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["node"])), nodeFilter) { - continue - } - if actionFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["action"])), actionFilter) { - continue - } - out = append(out, item) - } - return out -} - -func filteredNodeAlerts(alerts []map[string]interface{}, nodeFilter string) []map[string]interface{} { - if nodeFilter == "" { - return alerts - } - out := make([]map[string]interface{}, 0, len(alerts)) - for _, item := range alerts { - if strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["node"])), nodeFilter) { - out = append(out, item) - } - } - return out -} - -func (s *Server) setArtifactStats(summary map[string]interface{}) { - s.artifactStatsMu.Lock() - defer s.artifactStatsMu.Unlock() - if summary == nil { - s.artifactStats = map[string]interface{}{} - return - } - copySummary := make(map[string]interface{}, len(summary)) - for k, v := range summary { - copySummary[k] = v - } - s.artifactStats = copySummary -} - -func (s *Server) artifactStatsSnapshot() map[string]interface{} { - s.artifactStatsMu.Lock() - defer s.artifactStatsMu.Unlock() - out := make(map[string]interface{}, len(s.artifactStats)) - for k, v := range s.artifactStats { - out[k] = v - } - return out -} - -func (s *Server) nodeArtifactRetentionConfig() cfgpkg.GatewayNodesArtifactsConfig { - cfg := cfgpkg.DefaultConfig() - if strings.TrimSpace(s.configPath) != "" { - if loaded, err := cfgpkg.LoadConfig(s.configPath); err == nil && loaded != nil { - cfg = loaded - } - } - return cfg.Gateway.Nodes.Artifacts -} - -func (s *Server) applyNodeArtifactRetention() map[string]interface{} { - retention := s.nodeArtifactRetentionConfig() - if !retention.Enabled || !retention.PruneOnRead || retention.KeepLatest <= 0 { - summary := map[string]interface{}{ - "enabled": retention.Enabled, - "keep_latest": retention.KeepLatest, - "retain_days": retention.RetainDays, - "prune_on_read": retention.PruneOnRead, - "pruned": 0, - "last_run_at": time.Now().UTC().Format(time.RFC3339), - } - s.setArtifactStats(summary) - return summary - } - items := s.webUINodeArtifactsPayload(0) - cutoff := time.Time{} - if retention.RetainDays > 0 { - cutoff = time.Now().UTC().Add(-time.Duration(retention.RetainDays) * 24 * time.Hour) - } - pruned := 0 - prunedByAge := 0 - prunedByCount := 0 - for index, item := range items { - drop := false - dropByAge := false - if !cutoff.IsZero() { - if tm, err := time.Parse(time.RFC3339, strings.TrimSpace(fmt.Sprint(item["time"]))); err == nil && tm.Before(cutoff) { - drop = true - dropByAge = true - } - } - if !drop && index >= retention.KeepLatest { - drop = true - } - if !drop { - continue - } - _, deletedAudit, _ := s.deleteNodeArtifact(strings.TrimSpace(fmt.Sprint(item["id"]))) - if deletedAudit { - pruned++ - if dropByAge { - prunedByAge++ - } else { - prunedByCount++ - } - } - } - summary := map[string]interface{}{ - "enabled": true, - "keep_latest": retention.KeepLatest, - "retain_days": retention.RetainDays, - "prune_on_read": retention.PruneOnRead, - "pruned": pruned, - "pruned_by_age": prunedByAge, - "pruned_by_count": prunedByCount, - "remaining": len(s.webUINodeArtifactsPayload(0)), - "last_run_at": time.Now().UTC().Format(time.RFC3339), - } - s.setArtifactStats(summary) - return summary -} - -func (s *Server) deleteNodeArtifact(id string) (bool, bool, error) { - id = strings.TrimSpace(id) - if id == "" { - return false, false, fmt.Errorf("id is required") - } - rows, auditPath := s.readNodeDispatchAuditRows() - if len(rows) == 0 || auditPath == "" { - return false, false, fmt.Errorf("artifact audit is empty") - } - deletedFile := false - deletedAudit := false - for rowIndex, row := range rows { - artifacts, _ := row["artifacts"].([]interface{}) - if len(artifacts) == 0 { - continue - } - nextArtifacts := make([]interface{}, 0, len(artifacts)) - for artifactIndex, raw := range artifacts { - artifact, ok := raw.(map[string]interface{}) - if !ok { - nextArtifacts = append(nextArtifacts, raw) - continue - } - if buildNodeArtifactID(row, artifact, artifactIndex) != id { - nextArtifacts = append(nextArtifacts, artifact) - continue - } - for _, rawPath := range []string{fmt.Sprint(artifact["source_path"]), fmt.Sprint(artifact["path"])} { - if path := resolveArtifactPath(s.workspacePath, rawPath); path != "" { - if err := os.Remove(path); err == nil { - deletedFile = true - break - } - } - } - deletedAudit = true - } - if deletedAudit { - row["artifacts"] = nextArtifacts - row["artifact_count"] = len(nextArtifacts) - kinds := make([]string, 0, len(nextArtifacts)) - for _, raw := range nextArtifacts { - if artifact, ok := raw.(map[string]interface{}); ok { - if kind := strings.TrimSpace(fmt.Sprint(artifact["kind"])); kind != "" { - kinds = append(kinds, kind) - } - } - } - if len(kinds) > 0 { - row["artifact_kinds"] = kinds - } else { - delete(row, "artifact_kinds") - } - rows[rowIndex] = row - break - } - } - if !deletedAudit { - return false, false, fmt.Errorf("artifact not found") - } - var buf bytes.Buffer - for _, row := range rows { - encoded, err := json.Marshal(row) - if err != nil { - continue - } - buf.Write(encoded) - buf.WriteByte('\n') - } - if err := os.WriteFile(auditPath, buf.Bytes(), 0644); err != nil { - return deletedFile, false, err - } - return deletedFile, true, nil -} - func (s *Server) handleWebUITools(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -2469,595 +1651,10 @@ func (s *Server) handleWebUIMCPInstall(w http.ResponseWriter, r *http.Request) { }) } -func (s *Server) handleWebUINodes(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - switch r.Method { - case http.MethodGet: - payload := s.webUINodesPayload(r.Context()) - payload["ok"] = true - writeJSON(w, payload) - case http.MethodPost: - var body struct { - Action string `json:"action"` - ID string `json:"id"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - action := strings.ToLower(body.Action) - if action != "delete" { - http.Error(w, "unsupported action", http.StatusBadRequest) - return - } - if s.mgr == nil { - http.Error(w, "nodes manager unavailable", http.StatusInternalServerError) - return - } - id := body.ID - ok := s.mgr.Remove(id) - writeJSON(w, map[string]interface{}{"ok": true, "deleted": ok, "id": id}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (s *Server) handleWebUINodeDispatches(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - limit := queryBoundedPositiveInt(r, "limit", 50, 500) - writeJSON(w, map[string]interface{}{ - "ok": true, - "items": s.webUINodesDispatchPayload(limit), - }) -} - -func (s *Server) handleWebUINodeDispatchReplay(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if s.onNodeDispatch == nil { - http.Error(w, "node dispatch handler not configured", http.StatusServiceUnavailable) - return - } - var body struct { - Node string `json:"node"` - Action string `json:"action"` - Mode string `json:"mode"` - Task string `json:"task"` - Model string `json:"model"` - Args map[string]interface{} `json:"args"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - req := nodes.Request{ - Node: strings.TrimSpace(body.Node), - Action: strings.TrimSpace(body.Action), - Task: body.Task, - Model: body.Model, - Args: body.Args, - } - if req.Node == "" || req.Action == "" { - http.Error(w, "node and action are required", http.StatusBadRequest) - return - } - resp, err := s.onNodeDispatch(r.Context(), req, strings.TrimSpace(body.Mode)) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - writeJSON(w, map[string]interface{}{ - "ok": true, - "result": resp, - }) -} - -func (s *Server) handleWebUINodeArtifacts(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - limit := queryBoundedPositiveInt(r, "limit", 200, 1000) - retentionSummary := s.applyNodeArtifactRetention() - nodeFilter := strings.TrimSpace(r.URL.Query().Get("node")) - actionFilter := strings.TrimSpace(r.URL.Query().Get("action")) - kindFilter := strings.TrimSpace(r.URL.Query().Get("kind")) - writeJSON(w, map[string]interface{}{ - "ok": true, - "items": s.webUINodeArtifactsPayloadFiltered(nodeFilter, actionFilter, kindFilter, limit), - "artifact_retention": retentionSummary, - }) -} - -func (s *Server) handleWebUINodeArtifactsExport(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - retentionSummary := s.applyNodeArtifactRetention() - limit := queryBoundedPositiveInt(r, "limit", 200, 1000) - nodeFilter := strings.TrimSpace(r.URL.Query().Get("node")) - actionFilter := strings.TrimSpace(r.URL.Query().Get("action")) - kindFilter := strings.TrimSpace(r.URL.Query().Get("kind")) - artifacts := s.webUINodeArtifactsPayloadFiltered(nodeFilter, actionFilter, kindFilter, limit) - dispatches := s.filteredNodeDispatches(nodeFilter, actionFilter, limit) - payload := s.webUINodesPayload(r.Context()) - nodeList, _ := payload["nodes"].([]nodes.NodeInfo) - p2p, _ := payload["p2p"].(map[string]interface{}) - alerts := filteredNodeAlerts(s.webUINodeAlertsPayload(nodeList, p2p, dispatches), nodeFilter) - - var archive bytes.Buffer - zw := zip.NewWriter(&archive) - writeJSON := func(name string, value interface{}) error { - entry, err := zw.Create(name) - if err != nil { - return err - } - enc := json.NewEncoder(entry) - enc.SetIndent("", " ") - return enc.Encode(value) - } - manifest := map[string]interface{}{ - "generated_at": time.Now().UTC().Format(time.RFC3339), - "filters": map[string]interface{}{ - "node": nodeFilter, - "action": actionFilter, - "kind": kindFilter, - "limit": limit, - }, - "artifact_count": len(artifacts), - "dispatch_count": len(dispatches), - "alert_count": len(alerts), - "retention": retentionSummary, - } - if err := writeJSON("manifest.json", manifest); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := writeJSON("dispatches.json", dispatches); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := writeJSON("alerts.json", alerts); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := writeJSON("artifacts.json", artifacts); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - for _, item := range artifacts { - name := sanitizeZipEntryName(firstNonEmptyString( - fmt.Sprint(item["name"]), - fmt.Sprint(item["source_path"]), - fmt.Sprint(item["path"]), - fmt.Sprintf("%s.bin", fmt.Sprint(item["id"])), - )) - raw, _, err := readArtifactBytes(s.workspacePath, item) - entryName := filepath.ToSlash(filepath.Join("files", fmt.Sprintf("%s-%s", fmt.Sprint(item["id"]), name))) - if err != nil || len(raw) == 0 { - entryName = filepath.ToSlash(filepath.Join("files", fmt.Sprintf("%s-metadata.json", fmt.Sprint(item["id"])))) - raw, err = json.MarshalIndent(item, "", " ") - if err != nil { - continue - } - } - entry, err := zw.Create(entryName) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if _, err := entry.Write(raw); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } - if err := zw.Close(); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - filename := "node-artifacts-export.zip" - if nodeFilter != "" { - filename = fmt.Sprintf("node-artifacts-%s.zip", sanitizeZipEntryName(nodeFilter)) - } - w.Header().Set("Content-Type", "application/zip") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(archive.Bytes()) -} - -func (s *Server) handleWebUINodeArtifactDownload(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - id := strings.TrimSpace(r.URL.Query().Get("id")) - if id == "" { - http.Error(w, "id is required", http.StatusBadRequest) - return - } - item, ok := s.findNodeArtifactByID(id) - if !ok { - http.Error(w, "artifact not found", http.StatusNotFound) - return - } - name := strings.TrimSpace(fmt.Sprint(item["name"])) - if name == "" { - name = "artifact" - } - mimeType := strings.TrimSpace(fmt.Sprint(item["mime_type"])) - if mimeType == "" { - mimeType = "application/octet-stream" - } - if contentB64 := strings.TrimSpace(fmt.Sprint(item["content_base64"])); contentB64 != "" { - payload, err := base64.StdEncoding.DecodeString(contentB64) - if err != nil { - http.Error(w, "invalid inline artifact payload", http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", mimeType) - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) - _, _ = w.Write(payload) - return - } - for _, rawPath := range []string{fmt.Sprint(item["source_path"]), fmt.Sprint(item["path"])} { - if path := resolveArtifactPath(s.workspacePath, rawPath); path != "" { - http.ServeFile(w, r, path) - return - } - } - if contentText := fmt.Sprint(item["content_text"]); strings.TrimSpace(contentText) != "" { - w.Header().Set("Content-Type", mimeType) - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) - _, _ = w.Write([]byte(contentText)) - return - } - http.Error(w, "artifact content unavailable", http.StatusNotFound) -} - -func (s *Server) handleWebUINodeArtifactDelete(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var body struct { - ID string `json:"id"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - deletedFile, deletedAudit, err := s.deleteNodeArtifact(strings.TrimSpace(body.ID)) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - writeJSON(w, map[string]interface{}{ - "ok": true, - "id": strings.TrimSpace(body.ID), - "deleted_file": deletedFile, - "deleted_audit": deletedAudit, - }) -} - -func (s *Server) handleWebUINodeArtifactPrune(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var body struct { - Node string `json:"node"` - Action string `json:"action"` - Kind string `json:"kind"` - KeepLatest int `json:"keep_latest"` - Limit int `json:"limit"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - limit := body.Limit - if limit <= 0 || limit > 5000 { - limit = 5000 - } - keepLatest := body.KeepLatest - if keepLatest < 0 { - keepLatest = 0 - } - items := s.webUINodeArtifactsPayloadFiltered(strings.TrimSpace(body.Node), strings.TrimSpace(body.Action), strings.TrimSpace(body.Kind), limit) - pruned := 0 - deletedFiles := 0 - for index, item := range items { - if index < keepLatest { - continue - } - deletedFile, deletedAudit, err := s.deleteNodeArtifact(strings.TrimSpace(fmt.Sprint(item["id"]))) - if err != nil || !deletedAudit { - continue - } - pruned++ - if deletedFile { - deletedFiles++ - } - } - writeJSON(w, map[string]interface{}{ - "ok": true, - "pruned": pruned, - "deleted_files": deletedFiles, - "kept": keepLatest, - }) -} - -func (s *Server) buildNodeAgentTrees(ctx context.Context, nodeList []nodes.NodeInfo) []map[string]interface{} { - trees := make([]map[string]interface{}, 0, len(nodeList)) - localRegistry := s.fetchRegistryItems(ctx) - for _, node := range nodeList { - nodeID := strings.TrimSpace(node.ID) - items := []map[string]interface{}{} - source := "unavailable" - readonly := true - if nodeID == "local" { - items = localRegistry - source = "local_runtime" - readonly = false - } else if remoteItems, err := s.fetchRemoteNodeRegistry(ctx, node); err == nil { - items = remoteItems - source = "remote_webui" - } - trees = append(trees, map[string]interface{}{ - "node_id": nodeID, - "node_name": fallbackNodeName(node), - "online": node.Online, - "source": source, - "readonly": readonly, - "root": buildAgentTreeRoot(nodeID, items), - }) - } - return trees -} - -func (s *Server) fetchRegistryItems(ctx context.Context) []map[string]interface{} { - _ = ctx - if s == nil || strings.TrimSpace(s.configPath) == "" { - return nil - } - cfg, err := cfgpkg.LoadConfig(strings.TrimSpace(s.configPath)) - if err != nil || cfg == nil { - return nil - } - items := make([]map[string]interface{}, 0, len(cfg.Agents.Subagents)) - for agentID, subcfg := range cfg.Agents.Subagents { - if !subcfg.Enabled { - continue - } - items = append(items, map[string]interface{}{ - "agent_id": agentID, - "display_name": subcfg.DisplayName, - "role": subcfg.Role, - "type": subcfg.Type, - "transport": fallbackString(strings.TrimSpace(subcfg.Transport), "local"), - }) - } - sort.Slice(items, func(i, j int) bool { - return strings.TrimSpace(stringFromMap(items[i], "agent_id")) < strings.TrimSpace(stringFromMap(items[j], "agent_id")) - }) - return items -} - -func (s *Server) fetchRemoteNodeRegistry(ctx context.Context, node nodes.NodeInfo) ([]map[string]interface{}, error) { - baseURL := nodeWebUIBaseURL(node) - if baseURL == "" { - return nil, fmt.Errorf("node %s endpoint missing", strings.TrimSpace(node.ID)) - } - reqURL := baseURL + "/api/config?mode=normalized" - if tok := strings.TrimSpace(node.Token); tok != "" { - reqURL += "&token=" + url.QueryEscape(tok) - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) - if err != nil { - return nil, err - } - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - return nil, fmt.Errorf("remote subagent registry unavailable: %s", strings.TrimSpace(node.ID)) - } - var payload struct { - OK bool `json:"ok"` - Config cfgpkg.NormalizedConfig `json:"config"` - RawConfig map[string]interface{} `json:"raw_config"` - } - if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil { - return nil, err - } - items := buildRegistryItemsFromNormalizedConfig(payload.Config) - if len(items) > 0 { - return items, nil - } - return nil, fmt.Errorf("remote subagent registry unavailable: %s", strings.TrimSpace(node.ID)) -} - -func buildRegistryItemsFromNormalizedConfig(view cfgpkg.NormalizedConfig) []map[string]interface{} { - items := make([]map[string]interface{}, 0, len(view.Core.Subagents)) - for agentID, subcfg := range view.Core.Subagents { - if strings.TrimSpace(agentID) == "" { - continue - } - items = append(items, map[string]interface{}{ - "agent_id": agentID, - "enabled": subcfg.Enabled, - "type": "subagent", - "transport": fallbackString(strings.TrimSpace(subcfg.RuntimeClass), "local"), - "node_id": "", - "parent_agent_id": "", - "notify_main_policy": "final_only", - "display_name": "", - "role": strings.TrimSpace(subcfg.Role), - "description": "", - "system_prompt_file": strings.TrimSpace(subcfg.Prompt), - "prompt_file_found": false, - "memory_namespace": "", - "tool_allowlist": append([]string(nil), subcfg.ToolAllowlist...), - "tool_visibility": map[string]interface{}{}, - "effective_tools": []string{}, - "inherited_tools": []string{}, - "routing_keywords": routeKeywordsForRegistry(view.Runtime.Router.Rules, agentID), - "managed_by": "config.json", - }) - } - sort.Slice(items, func(i, j int) bool { - return stringFromMap(items[i], "agent_id") < stringFromMap(items[j], "agent_id") - }) - return items -} - -func routeKeywordsForRegistry(rules []cfgpkg.AgentRouteRule, agentID string) []string { - agentID = strings.TrimSpace(agentID) - for _, rule := range rules { - if strings.TrimSpace(rule.AgentID) == agentID { - return append([]string(nil), rule.Keywords...) - } - } - return nil -} - -func nodeWebUIBaseURL(node nodes.NodeInfo) string { - endpoint := strings.TrimSpace(node.Endpoint) - if endpoint == "" || strings.EqualFold(endpoint, "gateway") { - return "" - } - if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { - return strings.TrimRight(endpoint, "/") - } - return "http://" + strings.TrimRight(endpoint, "/") -} - -func fallbackNodeName(node nodes.NodeInfo) string { - if name := strings.TrimSpace(node.Name); name != "" { - return name - } - if id := strings.TrimSpace(node.ID); id != "" { - return id - } - return "node" -} - -func buildAgentTreeRoot(nodeID string, items []map[string]interface{}) map[string]interface{} { - rootID := "main" - for _, item := range items { - if strings.TrimSpace(stringFromMap(item, "type")) == "router" && strings.TrimSpace(stringFromMap(item, "agent_id")) != "" { - rootID = strings.TrimSpace(stringFromMap(item, "agent_id")) - break - } - } - nodesByID := make(map[string]map[string]interface{}, len(items)+1) - for _, item := range items { - id := strings.TrimSpace(stringFromMap(item, "agent_id")) - if id == "" { - continue - } - nodesByID[id] = map[string]interface{}{ - "agent_id": id, - "display_name": stringFromMap(item, "display_name"), - "role": stringFromMap(item, "role"), - "type": stringFromMap(item, "type"), - "transport": fallbackString(stringFromMap(item, "transport"), "local"), - "managed_by": stringFromMap(item, "managed_by"), - "node_id": stringFromMap(item, "node_id"), - "parent_agent_id": stringFromMap(item, "parent_agent_id"), - "enabled": boolFromMap(item, "enabled"), - "children": []map[string]interface{}{}, - } - } - root, ok := nodesByID[rootID] - if !ok { - root = map[string]interface{}{ - "agent_id": rootID, - "display_name": "Main Agent", - "role": "orchestrator", - "type": "router", - "transport": "local", - "managed_by": "derived", - "enabled": true, - "children": []map[string]interface{}{}, - } - nodesByID[rootID] = root - } - for _, item := range items { - id := strings.TrimSpace(stringFromMap(item, "agent_id")) - if id == "" || id == rootID { - continue - } - parentID := strings.TrimSpace(stringFromMap(item, "parent_agent_id")) - if parentID == "" { - parentID = rootID - } - parent, ok := nodesByID[parentID] - if !ok { - parent = root - } - parent["children"] = append(parent["children"].([]map[string]interface{}), nodesByID[id]) - } - return map[string]interface{}{ - "node_id": nodeID, - "agent_id": root["agent_id"], - "root": root, - "child_cnt": len(root["children"].([]map[string]interface{})), - } -} - func stringFromMap(item map[string]interface{}, key string) string { return tools.MapStringArg(item, key) } -func boolFromMap(item map[string]interface{}, key string) bool { - if item == nil { - return false - } - v, _ := tools.MapBoolArg(item, key) - return v -} - func rawStringFromMap(item map[string]interface{}, key string) string { return tools.MapRawStringArg(item, key) } @@ -3526,7 +2123,6 @@ func gatewayBuildVersion() string { return "unknown" } - func firstNonEmptyString(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { @@ -4591,7 +3187,7 @@ func (s *Server) handleWebUILogsLive(w http.ResponseWriter, r *http.Request) { http.Error(w, "log path not configured", http.StatusInternalServerError) return } - conn, err := nodesWebsocketUpgrader.Upgrade(w, r, nil) + conn, err := websocketUpgrader.Upgrade(w, r, nil) if err != nil { return } diff --git a/pkg/api/server_test.go b/pkg/api/server_test.go index c19c98f..d27dce1 100644 --- a/pkg/api/server_test.go +++ b/pkg/api/server_test.go @@ -1,12 +1,8 @@ package api import ( - "archive/zip" - "bytes" "context" "encoding/json" - "fmt" - "io" "net" "net/http" "net/http/httptest" @@ -20,157 +16,9 @@ import ( "time" cfgpkg "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/nodes" "github.com/gorilla/websocket" ) -func TestHandleWebUIWhatsAppStatus(t *testing.T) { - t.Parallel() - - bridge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/status": - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "state": "connected", - "connected": true, - "logged_in": true, - "bridge_addr": "127.0.0.1:3001", - "user_jid": "8613012345678@s.whatsapp.net", - "qr_available": false, - "last_event": "connected", - "updated_at": "2026-03-09T12:00:00+08:00", - }) - default: - http.NotFound(w, r) - } - })) - defer bridge.Close() - - tmp := t.TempDir() - cfgPath := filepath.Join(tmp, "config.json") - cfg := cfgpkg.DefaultConfig() - cfg.Logging.Enabled = false - cfg.Channels.WhatsApp.Enabled = true - cfg.Channels.WhatsApp.BridgeURL = "ws" + strings.TrimPrefix(bridge.URL, "http") + "/ws" - if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - - srv := NewServer("127.0.0.1", 0, "", nil) - srv.SetConfigPath(cfgPath) - - req := httptest.NewRequest(http.MethodGet, "/api/whatsapp/status", nil) - rec := httptest.NewRecorder() - srv.handleWebUIWhatsAppStatus(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), `"bridge_running":true`) { - t.Fatalf("expected bridge_running=true, got: %s", rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), `"user_jid":"8613012345678@s.whatsapp.net"`) { - t.Fatalf("expected user_jid in payload, got: %s", rec.Body.String()) - } -} - -func TestHandleWebUIWhatsAppQR(t *testing.T) { - t.Parallel() - - bridge := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/status": - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "state": "qr_ready", - "connected": false, - "logged_in": false, - "bridge_addr": "127.0.0.1:3001", - "qr_available": true, - "qr_code": "test-qr-code", - "last_event": "qr_ready", - "updated_at": "2026-03-09T12:00:00+08:00", - }) - default: - http.NotFound(w, r) - } - })) - defer bridge.Close() - - tmp := t.TempDir() - cfgPath := filepath.Join(tmp, "config.json") - cfg := cfgpkg.DefaultConfig() - cfg.Logging.Enabled = false - cfg.Channels.WhatsApp.Enabled = true - cfg.Channels.WhatsApp.BridgeURL = "ws" + strings.TrimPrefix(bridge.URL, "http") + "/ws" - if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil { - t.Fatalf("save config: %v", err) - } - - srv := NewServer("127.0.0.1", 0, "", nil) - srv.SetConfigPath(cfgPath) - - req := httptest.NewRequest(http.MethodGet, "/api/whatsapp/qr.svg", nil) - rec := httptest.NewRecorder() - srv.handleWebUIWhatsAppQR(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) - } - if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "image/svg+xml") { - t.Fatalf("expected svg content-type, got %q", ct) - } - if !strings.Contains(rec.Body.String(), " 65535 { errs = append(errs, fmt.Errorf("gateway.port must be in 1..65535")) } - switch strings.ToLower(strings.TrimSpace(cfg.Gateway.Nodes.P2P.Transport)) { - case "", "websocket_tunnel", "webrtc": - default: - errs = append(errs, fmt.Errorf("gateway.nodes.p2p.transport must be one of: websocket_tunnel, webrtc")) - } - errs = append(errs, validateNonEmptyStringList("gateway.nodes.p2p.stun_servers", cfg.Gateway.Nodes.P2P.STUNServers)...) - for i, server := range cfg.Gateway.Nodes.P2P.ICEServers { - prefix := fmt.Sprintf("gateway.nodes.p2p.ice_servers[%d]", i) - errs = append(errs, validateNonEmptyStringList(prefix+".urls", server.URLs)...) - needsAuth := false - for _, raw := range server.URLs { - u := strings.ToLower(strings.TrimSpace(raw)) - if strings.HasPrefix(u, "turn:") || strings.HasPrefix(u, "turns:") { - needsAuth = true - break - } - } - if needsAuth { - if strings.TrimSpace(server.Username) == "" { - errs = append(errs, fmt.Errorf("%s.username is required for turn/turns urls", prefix)) - } - if strings.TrimSpace(server.Credential) == "" { - errs = append(errs, fmt.Errorf("%s.credential is required for turn/turns urls", prefix)) - } - } - } - errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.action_tags", cfg.Gateway.Nodes.Dispatch.ActionTags)...) - errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.agent_tags", cfg.Gateway.Nodes.Dispatch.AgentTags)...) - errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.allow_actions", cfg.Gateway.Nodes.Dispatch.AllowActions)...) - errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.deny_actions", cfg.Gateway.Nodes.Dispatch.DenyActions)...) - errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.allow_agents", cfg.Gateway.Nodes.Dispatch.AllowAgents)...) - errs = append(errs, validateDispatchTagMap("gateway.nodes.dispatch.deny_agents", cfg.Gateway.Nodes.Dispatch.DenyAgents)...) - if cfg.Gateway.Nodes.Artifacts.Enabled && cfg.Gateway.Nodes.Artifacts.KeepLatest <= 0 { - errs = append(errs, fmt.Errorf("gateway.nodes.artifacts.keep_latest must be > 0 when enabled=true")) - } - if cfg.Gateway.Nodes.Artifacts.KeepLatest < 0 { - errs = append(errs, fmt.Errorf("gateway.nodes.artifacts.keep_latest must be >= 0")) - } - if cfg.Gateway.Nodes.Artifacts.RetainDays < 0 { - errs = append(errs, fmt.Errorf("gateway.nodes.artifacts.retain_days must be >= 0")) - } if cfg.Cron.MinSleepSec <= 0 { errs = append(errs, fmt.Errorf("cron.min_sleep_sec must be > 0")) } @@ -425,9 +384,9 @@ func validateSubagents(cfg *Config) []error { transport := strings.TrimSpace(raw.Transport) if transport != "" { switch transport { - case "local", "node": + case "local": default: - errs = append(errs, fmt.Errorf("agents.subagents.%s.transport must be one of: local, node", id)) + errs = append(errs, fmt.Errorf("agents.subagents.%s.transport must be one of: local", id)) } } if policy := strings.TrimSpace(raw.NotifyMainPolicy); policy != "" { @@ -437,9 +396,6 @@ func validateSubagents(cfg *Config) []error { errs = append(errs, fmt.Errorf("agents.subagents.%s.notify_main_policy must be one of: final_only, milestone, on_blocked, always, internal_only", id)) } } - if transport == "node" && strings.TrimSpace(raw.NodeID) == "" { - errs = append(errs, fmt.Errorf("agents.subagents.%s.node_id is required when transport=node", id)) - } if raw.Runtime.TimeoutSec < 0 { errs = append(errs, fmt.Errorf("agents.subagents.%s.runtime.timeout_sec must be >= 0", id)) } @@ -461,7 +417,7 @@ func validateSubagents(cfg *Config) []error { if raw.Tools.MaxParallelCalls < 0 { errs = append(errs, fmt.Errorf("agents.subagents.%s.tools.max_parallel_calls must be >= 0", id)) } - if raw.Enabled && transport != "node" && strings.TrimSpace(raw.SystemPromptFile) == "" { + if raw.Enabled && strings.TrimSpace(raw.SystemPromptFile) == "" { errs = append(errs, fmt.Errorf("agents.subagents.%s.system_prompt_file is required when enabled=true", id)) } if promptFile := strings.TrimSpace(raw.SystemPromptFile); promptFile != "" { diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go index 048fec8..c865f5d 100644 --- a/pkg/config/validate_test.go +++ b/pkg/config/validate_test.go @@ -93,29 +93,6 @@ func TestValidateSubagentsRequiresPromptFileWhenEnabled(t *testing.T) { } } -func TestValidateNodeBackedSubagentAllowsMissingPromptFile(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Agents.Router.Enabled = true - cfg.Agents.Router.MainAgentID = "main" - cfg.Agents.Subagents["main"] = SubagentConfig{ - Enabled: true, - Type: "router", - SystemPromptFile: "agents/main/AGENT.md", - } - cfg.Agents.Subagents["node.edge.main"] = SubagentConfig{ - Enabled: true, - Type: "worker", - Transport: "node", - NodeID: "edge", - } - - if errs := Validate(cfg); len(errs) != 0 { - t.Fatalf("expected node-backed config to be valid, got %v", errs) - } -} - func TestValidateSubagentsRejectsInvalidNotifyMainPolicy(t *testing.T) { t.Parallel() @@ -134,81 +111,6 @@ func TestValidateSubagentsRejectsInvalidNotifyMainPolicy(t *testing.T) { } } -func TestDefaultConfigDisablesGatewayNodeP2P(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - if cfg.Gateway.Nodes.P2P.Enabled { - t.Fatalf("expected gateway node p2p to be disabled by default") - } - if cfg.Gateway.Nodes.P2P.Transport != "websocket_tunnel" { - t.Fatalf("unexpected default gateway node p2p transport: %s", cfg.Gateway.Nodes.P2P.Transport) - } -} - -func TestValidateRejectsUnknownGatewayNodeP2PTransport(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Gateway.Nodes.P2P.Transport = "udp" - - if errs := Validate(cfg); len(errs) == 0 { - t.Fatalf("expected validation errors") - } -} - -func TestValidateGatewayNodeP2PIceServersAllowsStunOnly(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Gateway.Nodes.P2P.ICEServers = []GatewayICEConfig{ - {URLs: []string{"stun:stun.l.google.com:19302"}}, - } - - if errs := Validate(cfg); len(errs) != 0 { - t.Fatalf("expected config to be valid, got %v", errs) - } -} - -func TestValidateGatewayNodeP2PIceServersRequireTurnCredentials(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Gateway.Nodes.P2P.ICEServers = []GatewayICEConfig{ - {URLs: []string{"turn:turn.example.com:3478?transport=udp"}}, - } - - if errs := Validate(cfg); len(errs) == 0 { - t.Fatalf("expected validation errors") - } -} - -func TestValidateGatewayNodeDispatchRejectsEmptyTagKey(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Gateway.Nodes.Dispatch.ActionTags = map[string][]string{ - "": {"vision"}, - } - - if errs := Validate(cfg); len(errs) == 0 { - t.Fatalf("expected validation errors") - } -} - -func TestValidateGatewayNodeDispatchRejectsEmptyAllowNodeKey(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Gateway.Nodes.Dispatch.AllowActions = map[string][]string{ - "": {"screen_snapshot"}, - } - - if errs := Validate(cfg); len(errs) == 0 { - t.Fatalf("expected validation errors") - } -} - func TestValidateSentinelWebhookURLRejectsInvalidScheme(t *testing.T) { t.Parallel() @@ -233,47 +135,6 @@ func TestValidateSentinelWebhookURLAllowsHTTPS(t *testing.T) { } } -func TestDefaultConfigSetsNodeArtifactRetentionDefaults(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - if cfg.Gateway.Nodes.Artifacts.Enabled { - t.Fatalf("expected node artifact retention disabled by default") - } - if cfg.Gateway.Nodes.Artifacts.KeepLatest != 500 { - t.Fatalf("unexpected default keep_latest: %d", cfg.Gateway.Nodes.Artifacts.KeepLatest) - } - if cfg.Gateway.Nodes.Artifacts.RetainDays != 7 { - t.Fatalf("unexpected default retain_days: %d", cfg.Gateway.Nodes.Artifacts.RetainDays) - } - if !cfg.Gateway.Nodes.Artifacts.PruneOnRead { - t.Fatalf("expected prune_on_read enabled by default") - } -} - -func TestValidateNodeArtifactRetentionRequiresPositiveKeepLatestWhenEnabled(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Gateway.Nodes.Artifacts.Enabled = true - cfg.Gateway.Nodes.Artifacts.KeepLatest = 0 - - if errs := Validate(cfg); len(errs) == 0 { - t.Fatalf("expected validation errors") - } -} - -func TestValidateNodeArtifactRetentionRejectsNegativeRetainDays(t *testing.T) { - t.Parallel() - - cfg := DefaultConfig() - cfg.Gateway.Nodes.Artifacts.RetainDays = -1 - - if errs := Validate(cfg); len(errs) == 0 { - t.Fatalf("expected validation errors") - } -} - func TestValidateProviderOAuthAllowsEmptyModelsBeforeLogin(t *testing.T) { t.Parallel() diff --git a/pkg/nodes/manager.go b/pkg/nodes/manager.go deleted file mode 100644 index 11ff990..0000000 --- a/pkg/nodes/manager.go +++ /dev/null @@ -1,693 +0,0 @@ -package nodes - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "time" -) - -const defaultNodeTTL = 60 * time.Second - -// Manager keeps paired node metadata and basic routing helpers. -type Handler func(req Request) Response -type WireSender interface { - Send(msg WireMessage) error -} - -type Manager struct { - mu sync.RWMutex - nodes map[string]NodeInfo - handlers map[string]Handler - senders map[string]WireSender - pending map[string]chan WireMessage - nextWire uint64 - ttl time.Duration - auditPath string - statePath string - policy DispatchPolicy -} - -type DispatchPolicy struct { - PreferLocal bool - PreferP2P bool - AllowRelayFallback bool - ActionTags map[string][]string - AgentTags map[string][]string - AllowActions map[string][]string - DenyActions map[string][]string - AllowAgents map[string][]string - DenyAgents map[string][]string -} - -var defaultManager = NewManager() - -func DefaultManager() *Manager { return defaultManager } - -func NewManager() *Manager { - m := &Manager{ - nodes: map[string]NodeInfo{}, - handlers: map[string]Handler{}, - senders: map[string]WireSender{}, - pending: map[string]chan WireMessage{}, - ttl: defaultNodeTTL, - policy: DispatchPolicy{ - PreferP2P: true, - AllowRelayFallback: true, - ActionTags: map[string][]string{}, - AgentTags: map[string][]string{}, - AllowActions: map[string][]string{}, - DenyActions: map[string][]string{}, - AllowAgents: map[string][]string{}, - DenyAgents: map[string][]string{}, - }, - } - go m.reaperLoop() - return m -} - -func (m *Manager) SetAuditPath(path string) { - m.mu.Lock() - defer m.mu.Unlock() - m.auditPath = strings.TrimSpace(path) -} - -func (m *Manager) SetStatePath(path string) { - m.mu.Lock() - m.statePath = strings.TrimSpace(path) - m.mu.Unlock() - m.loadState() -} - -func (m *Manager) SetDispatchPolicy(policy DispatchPolicy) { - m.mu.Lock() - defer m.mu.Unlock() - m.policy = normalizeDispatchPolicy(policy) -} - -func (m *Manager) DispatchPolicy() DispatchPolicy { - m.mu.RLock() - defer m.mu.RUnlock() - return cloneDispatchPolicy(m.policy) -} - -func (m *Manager) Upsert(info NodeInfo) { - m.mu.Lock() - now := time.Now().UTC() - old, existed := m.nodes[info.ID] - info.LastSeenAt = now - info.Online = true - if existed { - if info.RegisteredAt.IsZero() { - info.RegisteredAt = old.RegisteredAt - } - if len(info.Tags) == 0 && len(old.Tags) > 0 { - info.Tags = append([]string(nil), old.Tags...) - } - if strings.TrimSpace(info.Endpoint) == "" { - info.Endpoint = old.Endpoint - } - if strings.TrimSpace(info.Token) == "" { - info.Token = old.Token - } - } else if info.RegisteredAt.IsZero() { - info.RegisteredAt = now - } - m.nodes[info.ID] = info - m.mu.Unlock() - m.saveState() - m.appendAudit("upsert", info.ID, map[string]interface{}{"existed": existed, "endpoint": info.Endpoint, "version": info.Version}) -} - -func (m *Manager) MarkOffline(id string) { - m.mu.Lock() - changed := false - if n, ok := m.nodes[id]; ok { - n.Online = false - m.nodes[id] = n - changed = true - } - m.mu.Unlock() - if changed { - m.saveState() - } -} - -func (m *Manager) Get(id string) (NodeInfo, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - n, ok := m.nodes[id] - return n, ok -} - -func (m *Manager) List() []NodeInfo { - m.mu.RLock() - defer m.mu.RUnlock() - out := make([]NodeInfo, 0, len(m.nodes)) - for _, n := range m.nodes { - out = append(out, n) - } - sort.Slice(out, func(i, j int) bool { return out[i].LastSeenAt.After(out[j].LastSeenAt) }) - return out -} - -func (m *Manager) Remove(id string) bool { - id = strings.TrimSpace(id) - if id == "" { - return false - } - m.mu.Lock() - _, exists := m.nodes[id] - if exists { - delete(m.nodes, id) - delete(m.handlers, id) - } - m.mu.Unlock() - if exists { - m.saveState() - m.appendAudit("delete", id, nil) - } - return exists -} - -func (m *Manager) RegisterHandler(nodeID string, h Handler) { - m.mu.Lock() - defer m.mu.Unlock() - if strings.TrimSpace(nodeID) == "" || h == nil { - return - } - m.handlers[nodeID] = h -} - -func (m *Manager) RegisterWireSender(nodeID string, sender WireSender) { - nodeID = strings.TrimSpace(nodeID) - if nodeID == "" { - return - } - m.mu.Lock() - defer m.mu.Unlock() - if sender == nil { - delete(m.senders, nodeID) - return - } - m.senders[nodeID] = sender -} - -func (m *Manager) HandleWireMessage(msg WireMessage) bool { - switch strings.ToLower(strings.TrimSpace(msg.Type)) { - case "node_response": - if strings.TrimSpace(msg.ID) == "" { - return false - } - m.mu.Lock() - ch := m.pending[msg.ID] - if ch != nil { - delete(m.pending, msg.ID) - } - m.mu.Unlock() - if ch == nil { - return false - } - select { - case ch <- msg: - default: - } - return true - default: - return false - } -} - -func (m *Manager) SendWireRequest(ctx context.Context, nodeID string, req Request) (Response, error) { - nodeID = strings.TrimSpace(nodeID) - if nodeID == "" { - return Response{}, fmt.Errorf("node id required") - } - m.mu.Lock() - sender := m.senders[nodeID] - if sender == nil { - m.mu.Unlock() - return Response{}, fmt.Errorf("node %s websocket sender unavailable", nodeID) - } - m.nextWire++ - wireID := fmt.Sprintf("wire-%d", m.nextWire) - ch := make(chan WireMessage, 1) - m.pending[wireID] = ch - m.mu.Unlock() - - msg := WireMessage{ - Type: "node_request", - ID: wireID, - To: nodeID, - Request: &req, - } - if err := sender.Send(msg); err != nil { - m.mu.Lock() - delete(m.pending, wireID) - m.mu.Unlock() - return Response{}, err - } - - select { - case <-ctx.Done(): - m.mu.Lock() - delete(m.pending, wireID) - m.mu.Unlock() - return Response{}, ctx.Err() - case incoming := <-ch: - if incoming.Response == nil { - return Response{}, fmt.Errorf("node %s returned empty response", nodeID) - } - return *incoming.Response, nil - } -} - -func (m *Manager) Invoke(req Request) (Response, bool) { - m.mu.RLock() - h, ok := m.handlers[req.Node] - m.mu.RUnlock() - if !ok { - return Response{}, false - } - resp := h(req) - if strings.TrimSpace(resp.Node) == "" { - resp.Node = req.Node - } - if strings.TrimSpace(resp.Action) == "" { - resp.Action = req.Action - } - return resp, true -} - -func (m *Manager) SupportsAction(nodeID, action string) bool { - n, ok := m.Get(nodeID) - if !ok || !n.Online { - return false - } - return nodeSupportsRequest(n, Request{Action: action}) -} - -func (m *Manager) SupportsRequest(nodeID string, req Request) bool { - n, ok := m.Get(nodeID) - if !ok || !n.Online { - return false - } - return nodeSupportsRequest(n, req) -} - -func nodeSupportsRequest(n NodeInfo, req Request) bool { - action := strings.ToLower(strings.TrimSpace(req.Action)) - if len(n.Actions) > 0 { - allowed := false - for _, a := range n.Actions { - if strings.ToLower(strings.TrimSpace(a)) == action { - allowed = true - break - } - } - if !allowed { - return false - } - } - switch action { - case "run": - return n.Capabilities.Run - case "agent_task": - return n.Capabilities.Model - case "camera_snap", "camera_clip": - return n.Capabilities.Camera - case "screen_record", "screen_snapshot": - return n.Capabilities.Screen - case "location_get": - return n.Capabilities.Location - case "canvas_snapshot", "canvas_action": - return n.Capabilities.Canvas - default: - return n.Capabilities.Invoke - } -} - -func (m *Manager) PickFor(action string) (NodeInfo, bool) { - return m.PickRequest(Request{Action: action}, "auto") -} - -func (m *Manager) PickRequest(req Request, mode string) (NodeInfo, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - bestScore := -1 - bestNode := NodeInfo{} - policy := normalizeDispatchPolicy(m.policy) - for _, n := range m.nodes { - score, ok := scoreNodeCandidate(n, req, mode, m.senders[strings.TrimSpace(n.ID)] != nil, policy) - if !ok { - continue - } - if score > bestScore || (score == bestScore && bestNode.ID != "" && n.LastSeenAt.After(bestNode.LastSeenAt)) { - bestScore = score - bestNode = n - } - } - if bestScore < 0 || strings.TrimSpace(bestNode.ID) == "" { - return NodeInfo{}, false - } - return bestNode, true -} - -func scoreNodeCandidate(n NodeInfo, req Request, mode string, hasWireSender bool, policy DispatchPolicy) (int, bool) { - if !n.Online { - return 0, false - } - if !nodeSupportsRequest(n, req) { - return 0, false - } - if !matchesDispatchPolicy(n, req, policy) { - return 0, false - } - - mode = strings.ToLower(strings.TrimSpace(mode)) - if mode == "p2p" && !hasWireSender { - return 0, false - } - if !policy.AllowRelayFallback && strings.TrimSpace(n.ID) != "local" && !hasWireSender { - return 0, false - } - - score := 100 - if hasWireSender { - score += 30 - } - if policy.PreferP2P { - if hasWireSender { - score += 35 - } else { - score -= 10 - } - } - if policy.PreferLocal && strings.EqualFold(strings.TrimSpace(n.ID), "local") { - score += 60 - } - if prefersRealtimeTransport(req.Action) && hasWireSender { - score += 40 - } - if mode == "relay" && hasWireSender { - score -= 10 - } - if mode == "p2p" && hasWireSender { - score += 80 - } - if strings.EqualFold(strings.TrimSpace(req.Action), "agent_task") { - remoteAgentID := requestedRemoteAgentID(req.Args) - switch { - case remoteAgentID == "", remoteAgentID == "main": - score += 20 - case nodeHasAgent(n, remoteAgentID): - score += 80 - default: - return 0, false - } - } - if !n.LastSeenAt.IsZero() { - ageSeconds := int(time.Since(n.LastSeenAt).Seconds()) - if ageSeconds < 0 { - ageSeconds = 0 - } - if ageSeconds < 60 { - score += 20 - } else if ageSeconds < 300 { - score += 5 - } - } - return score, true -} - -func normalizeDispatchPolicy(policy DispatchPolicy) DispatchPolicy { - normalized := DispatchPolicy{ - PreferLocal: policy.PreferLocal, - PreferP2P: policy.PreferP2P, - AllowRelayFallback: policy.AllowRelayFallback, - ActionTags: map[string][]string{}, - AgentTags: map[string][]string{}, - AllowActions: map[string][]string{}, - DenyActions: map[string][]string{}, - AllowAgents: map[string][]string{}, - DenyAgents: map[string][]string{}, - } - for key, tags := range policy.ActionTags { - trimmed := normalizeStringList(tags) - if len(trimmed) > 0 { - normalized.ActionTags[strings.ToLower(strings.TrimSpace(key))] = trimmed - } - } - for key, tags := range policy.AgentTags { - trimmed := normalizeStringList(tags) - if len(trimmed) > 0 { - normalized.AgentTags[strings.ToLower(strings.TrimSpace(key))] = trimmed - } - } - for key, tags := range policy.AllowActions { - trimmed := normalizeStringList(tags) - if len(trimmed) > 0 { - normalized.AllowActions[strings.ToLower(strings.TrimSpace(key))] = trimmed - } - } - for key, tags := range policy.DenyActions { - trimmed := normalizeStringList(tags) - if len(trimmed) > 0 { - normalized.DenyActions[strings.ToLower(strings.TrimSpace(key))] = trimmed - } - } - for key, tags := range policy.AllowAgents { - trimmed := normalizeStringList(tags) - if len(trimmed) > 0 { - normalized.AllowAgents[strings.ToLower(strings.TrimSpace(key))] = trimmed - } - } - for key, tags := range policy.DenyAgents { - trimmed := normalizeStringList(tags) - if len(trimmed) > 0 { - normalized.DenyAgents[strings.ToLower(strings.TrimSpace(key))] = trimmed - } - } - return normalized -} - -func cloneDispatchPolicy(policy DispatchPolicy) DispatchPolicy { - return normalizeDispatchPolicy(policy) -} - -func normalizeStringList(values []string) []string { - if len(values) == 0 { - return nil - } - seen := map[string]struct{}{} - out := make([]string, 0, len(values)) - for _, raw := range values { - trimmed := strings.ToLower(strings.TrimSpace(raw)) - if trimmed == "" { - continue - } - if _, ok := seen[trimmed]; ok { - continue - } - seen[trimmed] = struct{}{} - out = append(out, trimmed) - } - return out -} - -func matchesDispatchPolicy(n NodeInfo, req Request, policy DispatchPolicy) bool { - if !isNodePermittedByPolicy(n, req, policy) { - return false - } - if tags := policy.ActionTags[strings.ToLower(strings.TrimSpace(req.Action))]; len(tags) > 0 && !nodeMatchesAnyTag(n, tags) { - return false - } - remoteAgentID := requestedRemoteAgentID(req.Args) - if remoteAgentID != "" { - if tags := policy.AgentTags[remoteAgentID]; len(tags) > 0 && !nodeMatchesAnyTag(n, tags) { - return false - } - } - return true -} - -func isNodePermittedByPolicy(n NodeInfo, req Request, policy DispatchPolicy) bool { - nodeID := strings.ToLower(strings.TrimSpace(n.ID)) - action := strings.ToLower(strings.TrimSpace(req.Action)) - remoteAgentID := requestedRemoteAgentID(req.Args) - if remoteAgentID == "" && action == "agent_task" { - remoteAgentID = "main" - } - if deny := policy.DenyActions[nodeID]; len(deny) > 0 && containsNormalized(deny, action) { - return false - } - if allow := policy.AllowActions[nodeID]; len(allow) > 0 && !containsNormalized(allow, action) { - return false - } - if remoteAgentID != "" { - if deny := policy.DenyAgents[nodeID]; len(deny) > 0 && containsNormalized(deny, remoteAgentID) { - return false - } - if allow := policy.AllowAgents[nodeID]; len(allow) > 0 && !containsNormalized(allow, remoteAgentID) { - return false - } - } - return true -} - -func containsNormalized(items []string, target string) bool { - target = strings.ToLower(strings.TrimSpace(target)) - for _, item := range items { - if strings.ToLower(strings.TrimSpace(item)) == target { - return true - } - } - return false -} - -func nodeMatchesAnyTag(n NodeInfo, tags []string) bool { - if len(tags) == 0 { - return true - } - nodeTags := normalizeStringList(n.Tags) - if len(nodeTags) == 0 { - return false - } - seen := map[string]struct{}{} - for _, tag := range nodeTags { - seen[tag] = struct{}{} - } - for _, tag := range tags { - if _, ok := seen[strings.ToLower(strings.TrimSpace(tag))]; ok { - return true - } - } - return false -} - -func requestedRemoteAgentID(args map[string]interface{}) string { - if len(args) == 0 { - return "" - } - value, ok := args["remote_agent_id"] - if !ok || value == nil { - return "" - } - return strings.ToLower(strings.TrimSpace(fmt.Sprint(value))) -} - -func nodeHasAgent(n NodeInfo, agentID string) bool { - agentID = strings.ToLower(strings.TrimSpace(agentID)) - if agentID == "" { - return false - } - for _, agent := range n.Agents { - if strings.ToLower(strings.TrimSpace(agent.ID)) == agentID { - return true - } - } - return false -} - -func prefersRealtimeTransport(action string) bool { - switch strings.ToLower(strings.TrimSpace(action)) { - case "camera_snap", "camera_clip", "screen_record", "screen_snapshot", "canvas_snapshot", "canvas_action": - return true - default: - return false - } -} - -func (m *Manager) reaperLoop() { - t := time.NewTicker(15 * time.Second) - defer t.Stop() - for range t.C { - cutoff := time.Now().UTC().Add(-m.ttl) - m.mu.Lock() - offlined := make([]string, 0) - for id, n := range m.nodes { - if n.Online && !n.LastSeenAt.IsZero() && n.LastSeenAt.Before(cutoff) { - n.Online = false - m.nodes[id] = n - offlined = append(offlined, id) - } - } - m.mu.Unlock() - if len(offlined) > 0 { - m.saveState() - } - for _, id := range offlined { - m.appendAudit("offline_ttl", id, nil) - } - } -} - -func (m *Manager) appendAudit(event, nodeID string, data map[string]interface{}) { - m.mu.RLock() - path := m.auditPath - m.mu.RUnlock() - if strings.TrimSpace(path) == "" { - return - } - _ = os.MkdirAll(filepath.Dir(path), 0755) - row := map[string]interface{}{"time": time.Now().UTC().Format(time.RFC3339), "event": event, "node": nodeID} - for k, v := range data { - row[k] = v - } - b, _ := json.Marshal(row) - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - _, _ = f.Write(append(b, '\n')) -} - -func (m *Manager) saveState() { - m.mu.RLock() - path := m.statePath - items := make([]NodeInfo, 0, len(m.nodes)) - for _, n := range m.nodes { - items = append(items, n) - } - m.mu.RUnlock() - if strings.TrimSpace(path) == "" { - return - } - _ = os.MkdirAll(filepath.Dir(path), 0755) - b, err := json.MarshalIndent(items, "", " ") - if err != nil { - return - } - _ = os.WriteFile(path, b, 0644) -} - -func (m *Manager) loadState() { - m.mu.RLock() - path := m.statePath - m.mu.RUnlock() - if strings.TrimSpace(path) == "" { - return - } - b, err := os.ReadFile(path) - if err != nil { - return - } - var items []NodeInfo - if err := json.Unmarshal(b, &items); err != nil { - return - } - m.mu.Lock() - for _, n := range items { - if strings.TrimSpace(n.ID) == "" { - continue - } - m.nodes[n.ID] = n - } - m.mu.Unlock() -} diff --git a/pkg/nodes/manager_test.go b/pkg/nodes/manager_test.go deleted file mode 100644 index 13ce35c..0000000 --- a/pkg/nodes/manager_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package nodes - -import ( - "testing" - "time" -) - -func TestPickRequestPrefersMatchingRemoteAgent(t *testing.T) { - t.Parallel() - - manager := NewManager() - now := time.Now().UTC() - manager.Upsert(NodeInfo{ - ID: "node-main-only", - Online: true, - LastSeenAt: now, - Capabilities: Capabilities{ - Model: true, - }, - Agents: []AgentInfo{{ID: "main"}}, - }) - manager.Upsert(NodeInfo{ - ID: "node-coder", - Online: true, - LastSeenAt: now, - Capabilities: Capabilities{ - Model: true, - }, - Agents: []AgentInfo{{ID: "main"}, {ID: "coder"}}, - }) - - picked, ok := manager.PickRequest(Request{ - Action: "agent_task", - Args: map[string]interface{}{"remote_agent_id": "coder"}, - }, "auto") - if !ok { - t.Fatalf("expected node pick") - } - if picked.ID != "node-coder" { - t.Fatalf("expected node-coder, got %+v", picked) - } -} - -func TestPickRequestPrefersRealtimeCapableNodeForScreenActions(t *testing.T) { - t.Parallel() - - manager := NewManager() - now := time.Now().UTC() - manager.Upsert(NodeInfo{ - ID: "relay-only", - Online: true, - LastSeenAt: now.Add(-2 * time.Minute), - Capabilities: Capabilities{ - Screen: true, - }, - Actions: []string{"screen_snapshot"}, - }) - manager.Upsert(NodeInfo{ - ID: "p2p-ready", - Online: true, - LastSeenAt: now, - Capabilities: Capabilities{ - Screen: true, - }, - Actions: []string{"screen_snapshot"}, - }) - manager.RegisterWireSender("p2p-ready", &captureWireSender{}) - - picked, ok := manager.PickRequest(Request{Action: "screen_snapshot"}, "auto") - if !ok { - t.Fatalf("expected node pick") - } - if picked.ID != "p2p-ready" { - t.Fatalf("expected p2p-ready, got %+v", picked) - } -} - -func TestPickRequestHonorsActionTagsPolicy(t *testing.T) { - t.Parallel() - - manager := NewManager() - manager.SetDispatchPolicy(DispatchPolicy{ - PreferP2P: true, - AllowRelayFallback: true, - ActionTags: map[string][]string{ - "screen_snapshot": {"vision"}, - }, - }) - now := time.Now().UTC() - manager.Upsert(NodeInfo{ - ID: "build-node", - Tags: []string{"build"}, - Online: true, - LastSeenAt: now, - Capabilities: Capabilities{ - Screen: true, - }, - Actions: []string{"screen_snapshot"}, - }) - manager.Upsert(NodeInfo{ - ID: "vision-node", - Tags: []string{"vision"}, - Online: true, - LastSeenAt: now, - Capabilities: Capabilities{ - Screen: true, - }, - Actions: []string{"screen_snapshot"}, - }) - - picked, ok := manager.PickRequest(Request{Action: "screen_snapshot"}, "auto") - if !ok { - t.Fatalf("expected node pick") - } - if picked.ID != "vision-node" { - t.Fatalf("expected vision-node, got %+v", picked) - } -} - -func TestPickRequestHonorsPreferLocalPolicy(t *testing.T) { - t.Parallel() - - manager := NewManager() - manager.SetDispatchPolicy(DispatchPolicy{ - PreferLocal: true, - PreferP2P: false, - AllowRelayFallback: true, - }) - now := time.Now().UTC() - manager.Upsert(NodeInfo{ - ID: "local", - Online: true, - LastSeenAt: now.Add(-1 * time.Minute), - Capabilities: Capabilities{ - Run: true, - }, - Actions: []string{"run"}, - }) - manager.Upsert(NodeInfo{ - ID: "remote", - Online: true, - LastSeenAt: now, - Capabilities: Capabilities{ - Run: true, - }, - Actions: []string{"run"}, - }) - - picked, ok := manager.PickRequest(Request{Action: "run"}, "auto") - if !ok { - t.Fatalf("expected node pick") - } - if picked.ID != "local" { - t.Fatalf("expected local, got %+v", picked) - } -} - -func TestPickRequestHonorsNodeAllowActionsPolicy(t *testing.T) { - t.Parallel() - - manager := NewManager() - manager.SetDispatchPolicy(DispatchPolicy{ - AllowRelayFallback: true, - AllowActions: map[string][]string{ - "camera-node": {"camera_snap"}, - }, - }) - now := time.Now().UTC() - manager.Upsert(NodeInfo{ - ID: "camera-node", - Online: true, - LastSeenAt: now, - Capabilities: Capabilities{ - Camera: true, - Screen: true, - }, - Actions: []string{"camera_snap", "screen_snapshot"}, - }) - - if _, ok := manager.PickRequest(Request{Action: "screen_snapshot"}, "auto"); ok { - t.Fatalf("expected screen_snapshot to be blocked by allow_actions") - } - if _, ok := manager.PickRequest(Request{Action: "camera_snap"}, "auto"); !ok { - t.Fatalf("expected camera_snap to remain allowed") - } -} - -func TestPickRequestHonorsNodeDenyAgentsPolicy(t *testing.T) { - t.Parallel() - - manager := NewManager() - manager.SetDispatchPolicy(DispatchPolicy{ - AllowRelayFallback: true, - DenyAgents: map[string][]string{ - "edge-a": {"coder"}, - }, - }) - now := time.Now().UTC() - manager.Upsert(NodeInfo{ - ID: "edge-a", - Online: true, - LastSeenAt: now, - Capabilities: Capabilities{ - Model: true, - }, - Agents: []AgentInfo{{ID: "main"}, {ID: "coder"}}, - }) - - if _, ok := manager.PickRequest(Request{Action: "agent_task", Args: map[string]interface{}{"remote_agent_id": "coder"}}, "auto"); ok { - t.Fatalf("expected coder agent_task to be denied by policy") - } -} diff --git a/pkg/nodes/reload_unix.go b/pkg/nodes/reload_unix.go deleted file mode 100644 index 73fcd3e..0000000 --- a/pkg/nodes/reload_unix.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build !windows - -package nodes - -import ( - "os" - "syscall" -) - -func requestSelfReloadSignal() error { - return syscall.Kill(os.Getpid(), syscall.SIGHUP) -} diff --git a/pkg/nodes/reload_windows.go b/pkg/nodes/reload_windows.go deleted file mode 100644 index a0e28fe..0000000 --- a/pkg/nodes/reload_windows.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build windows - -package nodes - -// requestSelfReloadSignal is a no-op on Windows (no SIGHUP semantics). -func requestSelfReloadSignal() error { - return nil -} diff --git a/pkg/nodes/transport.go b/pkg/nodes/transport.go deleted file mode 100644 index e56ad80..0000000 --- a/pkg/nodes/transport.go +++ /dev/null @@ -1,356 +0,0 @@ -package nodes - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "path/filepath" - "strings" - "time" -) - -// Transport abstracts node data-plane delivery. -type Transport interface { - Name() string - Send(ctx context.Context, req Request) (Response, error) -} - -// Router prefers p2p transport and falls back to relay. -type Router struct { - P2P Transport - Relay Transport - Policy DispatchPolicy -} - -func (r *Router) Dispatch(ctx context.Context, req Request, mode string) (Response, error) { - m := strings.ToLower(strings.TrimSpace(mode)) - if m == "" { - m = "auto" - } - switch m { - case "p2p": - if r.P2P == nil { - return Response{OK: false, Node: req.Node, Action: req.Action, Error: "p2p transport unavailable"}, nil - } - resp, err := r.P2P.Send(ctx, req) - return annotateTransport(resp, "p2p", r.P2P.Name(), ""), err - case "relay": - if r.Relay == nil { - return Response{OK: false, Node: req.Node, Action: req.Action, Error: "relay transport unavailable"}, nil - } - resp, err := r.Relay.Send(ctx, req) - return annotateTransport(resp, "relay", r.Relay.Name(), ""), err - default: // auto - preferP2P := r.Policy.PreferP2P || r.Relay == nil - if preferP2P && r.P2P != nil { - if resp, err := r.P2P.Send(ctx, req); err == nil && resp.OK { - return annotateTransport(resp, "auto", r.P2P.Name(), ""), nil - } else if !r.Policy.AllowRelayFallback { - return annotateTransport(resp, "auto", r.P2P.Name(), ""), err - } - } - if r.Relay != nil { - resp, err := r.Relay.Send(ctx, req) - fallback := "" - if preferP2P && r.P2P != nil { - fallback = "p2p" - } - return annotateTransport(resp, "auto", r.Relay.Name(), fallback), err - } - if !preferP2P && r.P2P != nil { - resp, err := r.P2P.Send(ctx, req) - return annotateTransport(resp, "auto", r.P2P.Name(), "relay"), err - } - return Response{}, fmt.Errorf("no transport available") - } -} - -func annotateTransport(resp Response, mode, usedTransport, fallbackFrom string) Response { - if resp.Payload == nil { - resp.Payload = map[string]interface{}{} - } - if strings.TrimSpace(mode) != "" { - resp.Payload["dispatch_mode"] = strings.TrimSpace(mode) - } - if strings.TrimSpace(usedTransport) != "" { - resp.Payload["used_transport"] = strings.TrimSpace(usedTransport) - } - if strings.TrimSpace(fallbackFrom) != "" { - resp.Payload["fallback_from"] = strings.TrimSpace(fallbackFrom) - } - return resp -} - -// WebsocketP2PTransport uses the persistent node websocket as a request/response tunnel -// while the project evolves toward a true peer data channel. -type WebsocketP2PTransport struct { - Manager *Manager -} - -func (s *WebsocketP2PTransport) Name() string { return "p2p" } -func (s *WebsocketP2PTransport) Send(ctx context.Context, req Request) (Response, error) { - if s == nil || s.Manager == nil { - return Response{OK: false, Node: req.Node, Action: req.Action, Error: "p2p manager unavailable"}, nil - } - resp, err := s.Manager.SendWireRequest(ctx, req.Node, req) - if err != nil { - return Response{OK: false, Code: "p2p_unavailable", Node: req.Node, Action: req.Action, Error: err.Error()}, nil - } - resp.Payload = normalizeDevicePayload(resp.Action, resp.Payload) - return resp, nil -} - -// HTTPRelayTransport dispatches requests to node-agent endpoints over HTTP. -type HTTPRelayTransport struct { - Manager *Manager - Client *http.Client -} - -func (s *HTTPRelayTransport) Name() string { return "relay" } - -func actionHTTPPath(action string) string { - switch strings.ToLower(strings.TrimSpace(action)) { - case "run": - return "/run" - case "invoke": - return "/invoke" - case "agent_task": - return "/agent/task" - case "camera_snap": - return "/camera/snap" - case "camera_clip": - return "/camera/clip" - case "screen_record": - return "/screen/record" - case "screen_snapshot": - return "/screen/snapshot" - case "location_get": - return "/location/get" - case "canvas_snapshot": - return "/canvas/snapshot" - case "canvas_action": - return "/canvas/action" - default: - return "/invoke" - } -} - -func DoEndpointRequest(ctx context.Context, client *http.Client, endpoint, token string, req Request) (Response, error) { - endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") - if endpoint == "" { - return Response{OK: false, Code: "endpoint_missing", Node: req.Node, Action: req.Action, Error: "node endpoint not configured"}, nil - } - if client == nil { - client = &http.Client{Timeout: 20 * time.Second} - } - body, _ := json.Marshal(req) - path := actionHTTPPath(req.Action) - hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+path, bytes.NewReader(body)) - if err != nil { - return Response{}, err - } - hreq.Header.Set("Content-Type", "application/json") - if tok := strings.TrimSpace(token); tok != "" { - hreq.Header.Set("Authorization", "Bearer "+tok) - } - hresp, err := client.Do(hreq) - if err != nil { - return Response{OK: false, Code: "transport_error", Node: req.Node, Action: req.Action, Error: err.Error()}, nil - } - defer hresp.Body.Close() - payload, _ := io.ReadAll(io.LimitReader(hresp.Body, 1<<20)) - var resp Response - if err := json.Unmarshal(payload, &resp); err != nil { - return Response{OK: false, Code: "invalid_response", Node: req.Node, Action: req.Action, Error: fmt.Sprintf("invalid node response: %s", strings.TrimSpace(string(payload)))}, nil - } - if strings.TrimSpace(resp.Node) == "" { - resp.Node = req.Node - } - if strings.TrimSpace(resp.Action) == "" { - resp.Action = req.Action - } - if strings.TrimSpace(resp.Code) == "" { - if resp.OK { - resp.Code = "ok" - } else { - resp.Code = "remote_error" - } - } - resp.Payload = normalizeDevicePayload(resp.Action, resp.Payload) - return resp, nil -} - -func (s *HTTPRelayTransport) Send(ctx context.Context, req Request) (Response, error) { - if s.Manager == nil { - return Response{OK: false, Code: "relay_unavailable", Node: req.Node, Action: req.Action, Error: "relay manager not configured"}, nil - } - if resp, ok := s.Manager.Invoke(req); ok { - return resp, nil - } - n, ok := s.Manager.Get(req.Node) - if !ok { - return Response{OK: false, Code: "node_not_found", Node: req.Node, Action: req.Action, Error: "node not found"}, nil - } - return DoEndpointRequest(ctx, s.Client, n.Endpoint, n.Token, req) -} - -func normalizeDevicePayload(action string, payload map[string]interface{}) map[string]interface{} { - if payload == nil { - payload = map[string]interface{}{} - } - a := strings.ToLower(strings.TrimSpace(action)) - switch a { - case "camera_snap", "screen_snapshot", "canvas_snapshot": - if _, ok := payload["media_type"]; !ok { - payload["media_type"] = "image" - } - case "camera_clip", "screen_record": - if _, ok := payload["media_type"]; !ok { - payload["media_type"] = "video" - } - } - if _, ok := payload["storage"]; !ok { - if _, hasURL := payload["url"]; hasURL { - payload["storage"] = "url" - } else if _, hasPath := payload["path"]; hasPath { - payload["storage"] = "path" - } else if _, hasInline := payload["image"]; hasInline { - payload["storage"] = "inline" - } - } - if _, ok := payload["meta"]; !ok { - payload["meta"] = map[string]interface{}{} - } - payload["artifacts"] = normalizeArtifacts(payload, a) - return payload -} - -func normalizeArtifacts(payload map[string]interface{}, action string) []map[string]interface{} { - if payload == nil { - return []map[string]interface{}{} - } - if raw, ok := payload["artifacts"]; ok { - items := normalizeArtifactList(raw) - if len(items) > 0 { - return items - } - } - - artifact := map[string]interface{}{} - if mediaType := payloadString(payload, "media_type"); mediaType != "" { - artifact["kind"] = mediaType - } - if mimeType := payloadString(payload, "mime_type"); mimeType != "" { - artifact["mime_type"] = mimeType - } - if storage := payloadString(payload, "storage"); storage != "" { - artifact["storage"] = storage - } - if path := payloadString(payload, "path"); path != "" { - artifact["path"] = filepath.Clean(path) - } - if url := payloadString(payload, "url"); url != "" { - artifact["url"] = url - } - if image := payloadString(payload, "image"); image != "" { - artifact["content_base64"] = image - } - if text := payloadString(payload, "content_text"); text != "" { - artifact["content_text"] = text - } - if name := payloadString(payload, "name"); name != "" { - artifact["name"] = name - } - if size := int64FromPayload(payload["size_bytes"]); size > 0 { - artifact["size_bytes"] = size - } - if len(artifact) == 0 { - return []map[string]interface{}{} - } - if _, ok := artifact["kind"]; !ok && strings.TrimSpace(action) != "" { - artifact["kind"] = strings.ToLower(strings.TrimSpace(action)) - } - return []map[string]interface{}{artifact} -} - -func normalizeArtifactList(raw interface{}) []map[string]interface{} { - switch items := raw.(type) { - case []map[string]interface{}: - out := make([]map[string]interface{}, 0, len(items)) - for _, item := range items { - if normalized, ok := normalizeArtifactRow(item); ok { - out = append(out, normalized) - } - } - return out - case []interface{}: - out := make([]map[string]interface{}, 0, len(items)) - for _, item := range items { - row, ok := item.(map[string]interface{}) - if !ok { - continue - } - if normalized, ok := normalizeArtifactRow(row); ok { - out = append(out, normalized) - } - } - return out - default: - return []map[string]interface{}{} - } -} - -func normalizeArtifactRow(row map[string]interface{}) (map[string]interface{}, bool) { - if len(row) == 0 { - return nil, false - } - normalized := map[string]interface{}{} - for _, key := range []string{"id", "name", "kind", "mime_type", "storage", "path", "url", "content_text", "content_base64", "source_path"} { - if value, ok := row[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" { - normalized[key] = value - } - } - if truncated, ok := row["truncated"].(bool); ok && truncated { - normalized["truncated"] = true - } - if size := int64FromPayload(row["size_bytes"]); size > 0 { - normalized["size_bytes"] = size - } - if len(normalized) == 0 { - return nil, false - } - return normalized, true -} - -func int64FromPayload(v interface{}) int64 { - switch value := v.(type) { - case int: - return int64(value) - case int64: - return value - case float64: - return int64(value) - case json.Number: - n, _ := value.Int64() - return n - case string: - n, _ := json.Number(strings.TrimSpace(value)).Int64() - return n - default: - return 0 - } -} - -func payloadString(payload map[string]interface{}, key string) string { - if payload == nil { - return "" - } - v, ok := payload[key] - if !ok { - return "" - } - return strings.TrimSpace(fmt.Sprint(v)) -} diff --git a/pkg/nodes/transport_test.go b/pkg/nodes/transport_test.go deleted file mode 100644 index b408370..0000000 --- a/pkg/nodes/transport_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package nodes - -import ( - "context" - "encoding/json" - "path/filepath" - "sync" - "testing" - "time" - - "github.com/pion/webrtc/v4" -) - -type captureWireSender struct { - send func(msg WireMessage) error -} - -func (c *captureWireSender) Send(msg WireMessage) error { - if c.send != nil { - return c.send(msg) - } - return nil -} - -func TestWebsocketP2PTransportSend(t *testing.T) { - t.Parallel() - - manager := NewManager() - manager.Upsert(NodeInfo{ - ID: "edge-dev", - Online: true, - Capabilities: Capabilities{ - Run: true, - }, - }) - manager.RegisterWireSender("edge-dev", &captureWireSender{ - send: func(msg WireMessage) error { - if msg.Type != "node_request" || msg.Request == nil || msg.Request.Action != "run" { - t.Fatalf("unexpected wire request: %+v", msg) - } - go func() { - time.Sleep(20 * time.Millisecond) - manager.HandleWireMessage(WireMessage{ - Type: "node_response", - ID: msg.ID, - Response: &Response{ - OK: true, - Code: "ok", - Node: "edge-dev", - Action: "run", - Payload: map[string]interface{}{ - "status": "done", - }, - }, - }) - }() - return nil - }, - }) - - transport := &WebsocketP2PTransport{Manager: manager} - resp, err := transport.Send(context.Background(), Request{ - Action: "run", - Node: "edge-dev", - Args: map[string]interface{}{"command": []string{"echo", "ok"}}, - }) - if err != nil { - t.Fatalf("transport send failed: %v", err) - } - if !resp.OK || resp.Node != "edge-dev" || resp.Action != "run" { - t.Fatalf("unexpected response: %+v", resp) - } - if resp.Payload["status"] != "done" { - t.Fatalf("unexpected payload: %+v", resp.Payload) - } -} - -func TestNormalizeDevicePayloadBuildsArtifacts(t *testing.T) { - t.Parallel() - - path := filepath.Join(string(filepath.Separator), "tmp", "screen.png") - payload := normalizeDevicePayload("screen_snapshot", map[string]interface{}{ - "media_type": "image", - "storage": "path", - "path": path, - "mime_type": "image/png", - }) - artifacts, ok := payload["artifacts"].([]map[string]interface{}) - if !ok || len(artifacts) != 1 { - t.Fatalf("expected one artifact, got %+v", payload["artifacts"]) - } - if artifacts[0]["kind"] != "image" || filepath.Clean(artifacts[0]["path"].(string)) != filepath.Clean(path) { - t.Fatalf("unexpected artifact payload: %+v", artifacts[0]) - } -} - -func TestNormalizeDevicePayloadNormalizesExistingArtifactRows(t *testing.T) { - t.Parallel() - - path := filepath.Join(string(filepath.Separator), "tmp", "screen.png") - payload := normalizeDevicePayload("screen_snapshot", map[string]interface{}{ - "artifacts": []map[string]interface{}{ - { - "path": path, - "kind": "image", - "size_bytes": "42", - }, - }, - }) - artifacts, ok := payload["artifacts"].([]map[string]interface{}) - if !ok || len(artifacts) != 1 { - t.Fatalf("expected one normalized artifact, got %+v", payload["artifacts"]) - } - if got := artifacts[0]["size_bytes"]; got != int64(42) { - t.Fatalf("expected normalized size_bytes, got %#v", got) - } -} - -func TestWebRTCTransportSendEndToEnd(t *testing.T) { - t.Parallel() - - transport := NewWebRTCTransport(nil) - nodeID := "edge-webrtc" - - var remotePC *webrtc.PeerConnection - var remoteMu sync.Mutex - handleRemoteSignal := func(msg WireMessage) error { - remoteMu.Lock() - defer remoteMu.Unlock() - - ensureRemote := func() (*webrtc.PeerConnection, error) { - if remotePC != nil { - return remotePC, nil - } - pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) - if err != nil { - return nil, err - } - pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { - if candidate == nil { - return - } - _ = transport.HandleSignal(WireMessage{ - Type: "signal_candidate", - From: nodeID, - To: "gateway", - Session: nodeID, - Payload: structToMap(candidate.ToJSON()), - }) - }) - pc.OnDataChannel(func(dc *webrtc.DataChannel) { - dc.OnMessage(func(message webrtc.DataChannelMessage) { - var wire WireMessage - if err := json.Unmarshal(message.Data, &wire); err != nil { - return - } - if wire.Type != "node_request" || wire.Request == nil { - return - } - resp := Response{ - OK: true, - Code: "ok", - Node: nodeID, - Action: wire.Request.Action, - Payload: map[string]interface{}{ - "status": "done-over-webrtc", - }, - } - b, err := json.Marshal(WireMessage{ - Type: "node_response", - ID: wire.ID, - From: nodeID, - To: "gateway", - Response: &resp, - }) - if err != nil { - return - } - _ = dc.Send(b) - }) - }) - remotePC = pc - return remotePC, nil - } - - pc, err := ensureRemote() - if err != nil { - return err - } - - switch msg.Type { - case "signal_offer": - var desc webrtc.SessionDescription - if err := mapInto(msg.Payload, &desc); err != nil { - return err - } - if err := pc.SetRemoteDescription(desc); err != nil { - return err - } - answer, err := pc.CreateAnswer(nil) - if err != nil { - return err - } - if err := pc.SetLocalDescription(answer); err != nil { - return err - } - return transport.HandleSignal(WireMessage{ - Type: "signal_answer", - From: nodeID, - To: "gateway", - Session: nodeID, - Payload: structToMap(*pc.LocalDescription()), - }) - case "signal_candidate": - var candidate webrtc.ICECandidateInit - if err := mapInto(msg.Payload, &candidate); err != nil { - return err - } - return pc.AddICECandidate(candidate) - default: - return nil - } - } - - transport.BindSignaler(nodeID, &captureWireSender{ - send: handleRemoteSignal, - }) - defer func() { - transport.UnbindSignaler(nodeID) - remoteMu.Lock() - defer remoteMu.Unlock() - if remotePC != nil { - _ = remotePC.Close() - } - }() - - resp, err := transport.Send(context.Background(), Request{ - Action: "run", - Node: nodeID, - Args: map[string]interface{}{"command": []string{"echo", "ok"}}, - }) - if err != nil { - t.Fatalf("webrtc transport send failed: %v", err) - } - if !resp.OK { - t.Fatalf("expected ok response, got %+v", resp) - } - if resp.Payload["status"] != "done-over-webrtc" { - t.Fatalf("unexpected payload: %+v", resp.Payload) - } - if resp.Payload["used_transport"] != nil { - t.Fatalf("transport annotations should not be added at transport layer: %+v", resp.Payload) - } - - snapshot := transport.Snapshot() - if snapshot["active_sessions"] != 1 { - t.Fatalf("expected one active session, got %+v", snapshot) - } - nodesRaw, _ := snapshot["nodes"].([]map[string]interface{}) - if len(nodesRaw) == 0 { - t.Fatalf("expected node snapshots, got %+v", snapshot) - } - if nodesRaw[0]["status"] != "open" { - t.Fatalf("expected open status, got %+v", nodesRaw[0]) - } -} diff --git a/pkg/nodes/types.go b/pkg/nodes/types.go deleted file mode 100644 index 1448bbc..0000000 --- a/pkg/nodes/types.go +++ /dev/null @@ -1,98 +0,0 @@ -package nodes - -import "time" - -// Capability matrix reported by each node agent. -type Capabilities struct { - Run bool `json:"run"` - Invoke bool `json:"invoke"` - Model bool `json:"model"` - Camera bool `json:"camera"` - Screen bool `json:"screen"` - Location bool `json:"location"` - Canvas bool `json:"canvas"` -} - -// AgentInfo describes an enabled agent exposed by a remote clawgo node. -type AgentInfo struct { - ID string `json:"id"` - DisplayName string `json:"display_name,omitempty"` - Role string `json:"role,omitempty"` - Type string `json:"type,omitempty"` - Transport string `json:"transport,omitempty"` - ParentAgentID string `json:"parent_agent_id,omitempty"` -} - -// Artifact describes a file/media payload returned from a node action. -type Artifact struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Kind string `json:"kind,omitempty"` - MIMEType string `json:"mime_type,omitempty"` - Storage string `json:"storage,omitempty"` - Path string `json:"path,omitempty"` - URL string `json:"url,omitempty"` - ContentText string `json:"content_text,omitempty"` - ContentB64 string `json:"content_base64,omitempty"` - SizeBytes int64 `json:"size_bytes,omitempty"` - SourcePath string `json:"source_path,omitempty"` -} - -// NodeInfo is the runtime descriptor for cross-device scheduling. -type NodeInfo struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - Tags []string `json:"tags,omitempty"` - OS string `json:"os,omitempty"` - Arch string `json:"arch,omitempty"` - Version string `json:"version,omitempty"` - Endpoint string `json:"endpoint,omitempty"` - Token string `json:"token,omitempty"` - Capabilities Capabilities `json:"capabilities"` - Actions []string `json:"actions,omitempty"` - Models []string `json:"models,omitempty"` - Agents []AgentInfo `json:"agents,omitempty"` - RegisteredAt time.Time `json:"registered_at,omitempty"` - LastSeenAt time.Time `json:"last_seen_at"` - Online bool `json:"online"` -} - -// Envelope for node commands. -type Request struct { - Action string `json:"action"` - Node string `json:"node,omitempty"` - Task string `json:"task,omitempty"` - Model string `json:"model,omitempty"` - Args map[string]interface{} `json:"args,omitempty"` -} - -// Envelope for node responses. -type Response struct { - OK bool `json:"ok"` - Code string `json:"code,omitempty"` - Error string `json:"error,omitempty"` - Node string `json:"node,omitempty"` - Action string `json:"action,omitempty"` - Payload map[string]interface{} `json:"payload,omitempty"` -} - -// WireMessage is the websocket envelope for node lifecycle messages. -type WireMessage struct { - Type string `json:"type"` - ID string `json:"id,omitempty"` - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` - Session string `json:"session,omitempty"` - Node *NodeInfo `json:"node,omitempty"` - Request *Request `json:"request,omitempty"` - Response *Response `json:"response,omitempty"` - Payload map[string]interface{} `json:"payload,omitempty"` -} - -// WireAck is the websocket response envelope for node lifecycle messages. -type WireAck struct { - OK bool `json:"ok"` - Type string `json:"type"` - ID string `json:"id,omitempty"` - Error string `json:"error,omitempty"` -} diff --git a/pkg/nodes/webrtc.go b/pkg/nodes/webrtc.go deleted file mode 100644 index 3222276..0000000 --- a/pkg/nodes/webrtc.go +++ /dev/null @@ -1,439 +0,0 @@ -package nodes - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "sync" - "time" - - "github.com/pion/webrtc/v4" -) - -type gatewayRTCSession struct { - nodeID string - pc *webrtc.PeerConnection - dc *webrtc.DataChannel - ready chan struct{} - readyMu sync.Once - writeMu sync.Mutex - pending map[string]chan Response - mu sync.Mutex - nextID uint64 - status string - lastError string - retryCount int - createdAt time.Time - lastAttempt time.Time - lastReadyAt time.Time -} - -func (s *gatewayRTCSession) markReady() { - s.mu.Lock() - s.status = "open" - s.lastReadyAt = time.Now().UTC() - s.mu.Unlock() - s.readyMu.Do(func() { close(s.ready) }) -} - -func (s *gatewayRTCSession) send(msg WireMessage) error { - if s == nil || s.dc == nil { - return fmt.Errorf("webrtc data channel unavailable") - } - b, err := json.Marshal(msg) - if err != nil { - return err - } - s.writeMu.Lock() - defer s.writeMu.Unlock() - return s.dc.Send(b) -} - -func (s *gatewayRTCSession) nextRequestID() string { - s.mu.Lock() - defer s.mu.Unlock() - s.nextID++ - return fmt.Sprintf("rtc-%s-%d", s.nodeID, s.nextID) -} - -func (s *gatewayRTCSession) setStatus(status string) { - s.mu.Lock() - defer s.mu.Unlock() - s.status = strings.TrimSpace(status) -} - -func (s *gatewayRTCSession) setLastError(err error) { - s.mu.Lock() - defer s.mu.Unlock() - if err == nil { - s.lastError = "" - return - } - s.lastError = strings.TrimSpace(err.Error()) -} - -func (s *gatewayRTCSession) snapshot() map[string]interface{} { - s.mu.Lock() - defer s.mu.Unlock() - status := s.status - if status == "" { - status = "connecting" - } - return map[string]interface{}{ - "node": s.nodeID, - "status": status, - "last_error": s.lastError, - "retry_count": s.retryCount, - "created_at": s.createdAt, - "last_attempt": s.lastAttempt, - "last_ready_at": s.lastReadyAt, - } -} - -type WebRTCTransport struct { - iceServers []webrtc.ICEServer - - mu sync.Mutex - sessions map[string]*gatewayRTCSession - signal map[string]WireSender -} - -func NewWebRTCTransport(stunServers []string, extraICEServers ...webrtc.ICEServer) *WebRTCTransport { - out := make([]webrtc.ICEServer, 0, len(stunServers)+len(extraICEServers)) - for _, server := range stunServers { - if v := strings.TrimSpace(server); v != "" { - out = append(out, webrtc.ICEServer{URLs: []string{v}}) - } - } - for _, server := range extraICEServers { - urls := make([]string, 0, len(server.URLs)) - for _, raw := range server.URLs { - if v := strings.TrimSpace(raw); v != "" { - urls = append(urls, v) - } - } - if len(urls) == 0 { - continue - } - out = append(out, webrtc.ICEServer{ - URLs: urls, - Username: strings.TrimSpace(server.Username), - Credential: server.Credential, - }) - } - return &WebRTCTransport{ - iceServers: out, - sessions: map[string]*gatewayRTCSession{}, - signal: map[string]WireSender{}, - } -} - -func (t *WebRTCTransport) Name() string { return "p2p-webrtc" } - -func (t *WebRTCTransport) Snapshot() map[string]interface{} { - t.mu.Lock() - defer t.mu.Unlock() - nodes := make([]map[string]interface{}, 0, len(t.sessions)) - active := 0 - for nodeID, session := range t.sessions { - if session != nil && session.dc != nil && session.dc.ReadyState() == webrtc.DataChannelStateOpen { - active++ - } - if session == nil { - nodes = append(nodes, map[string]interface{}{"node": nodeID, "status": "unknown"}) - continue - } - nodes = append(nodes, session.snapshot()) - } - return map[string]interface{}{ - "transport": "webrtc", - "active_sessions": active, - "ice_servers": len(t.iceServers), - "nodes": nodes, - } -} - -func (t *WebRTCTransport) BindSignaler(nodeID string, sender WireSender) { - nodeID = strings.TrimSpace(nodeID) - if nodeID == "" { - return - } - t.mu.Lock() - defer t.mu.Unlock() - if sender == nil { - delete(t.signal, nodeID) - return - } - t.signal[nodeID] = sender -} - -func (t *WebRTCTransport) UnbindSignaler(nodeID string) { - t.BindSignaler(nodeID, nil) - t.mu.Lock() - session := t.sessions[nodeID] - delete(t.sessions, nodeID) - t.mu.Unlock() - if session != nil && session.pc != nil { - session.setStatus("offline") - _ = session.pc.Close() - } -} - -func (t *WebRTCTransport) currentSignaler(nodeID string) WireSender { - t.mu.Lock() - defer t.mu.Unlock() - return t.signal[strings.TrimSpace(nodeID)] -} - -func (t *WebRTCTransport) HandleSignal(msg WireMessage) error { - nodeID := strings.TrimSpace(msg.From) - if nodeID == "" { - return fmt.Errorf("signal missing from") - } - session, err := t.ensureSession(nodeID) - if err != nil { - return err - } - switch strings.ToLower(strings.TrimSpace(msg.Type)) { - case "signal_answer": - var desc webrtc.SessionDescription - if err := mapInto(msg.Payload, &desc); err != nil { - return err - } - return session.pc.SetRemoteDescription(desc) - case "signal_candidate": - var candidate webrtc.ICECandidateInit - if err := mapInto(msg.Payload, &candidate); err != nil { - return err - } - return session.pc.AddICECandidate(candidate) - default: - return fmt.Errorf("unsupported signal type: %s", msg.Type) - } -} - -func (t *WebRTCTransport) Send(ctx context.Context, req Request) (Response, error) { - session, err := t.ensureSession(req.Node) - if err != nil { - return Response{OK: false, Code: "p2p_unavailable", Node: req.Node, Action: req.Action, Error: err.Error()}, nil - } - session.setLastError(nil) - - select { - case <-ctx.Done(): - session.setStatus("cancelled") - session.setLastError(ctx.Err()) - return Response{}, ctx.Err() - case <-session.ready: - case <-time.After(8 * time.Second): - session.setStatus("timeout") - session.setLastError(fmt.Errorf("webrtc session not ready")) - return Response{OK: false, Code: "p2p_timeout", Node: req.Node, Action: req.Action, Error: "webrtc session not ready"}, nil - } - - reqID := session.nextRequestID() - respCh := make(chan Response, 1) - session.mu.Lock() - session.pending[reqID] = respCh - session.mu.Unlock() - - if err := session.send(WireMessage{ - Type: "node_request", - ID: reqID, - To: req.Node, - Request: &req, - }); err != nil { - session.mu.Lock() - delete(session.pending, reqID) - session.mu.Unlock() - session.setStatus("send_failed") - session.setLastError(err) - return Response{OK: false, Code: "p2p_send_failed", Node: req.Node, Action: req.Action, Error: err.Error()}, nil - } - - select { - case <-ctx.Done(): - session.mu.Lock() - delete(session.pending, reqID) - session.mu.Unlock() - session.setStatus("cancelled") - session.setLastError(ctx.Err()) - return Response{}, ctx.Err() - case resp := <-respCh: - if resp.OK { - session.setStatus("open") - session.setLastError(nil) - } else if strings.TrimSpace(resp.Error) != "" { - session.setStatus("remote_error") - session.setLastError(fmt.Errorf("%s", strings.TrimSpace(resp.Error))) - } - return resp, nil - } -} - -func (t *WebRTCTransport) ensureSession(nodeID string) (*gatewayRTCSession, error) { - nodeID = strings.TrimSpace(nodeID) - if nodeID == "" { - return nil, fmt.Errorf("node id required") - } - - t.mu.Lock() - if session := t.sessions[nodeID]; session != nil { - session.mu.Lock() - session.retryCount++ - session.lastAttempt = time.Now().UTC() - session.mu.Unlock() - t.mu.Unlock() - return session, nil - } - t.mu.Unlock() - if t.currentSignaler(nodeID) == nil { - return nil, fmt.Errorf("node %s signaling unavailable", nodeID) - } - - config := webrtc.Configuration{} - if len(t.iceServers) > 0 { - config.ICEServers = append([]webrtc.ICEServer(nil), t.iceServers...) - } - pc, err := webrtc.NewPeerConnection(config) - if err != nil { - return nil, err - } - dc, err := pc.CreateDataChannel("clawgo", nil) - if err != nil { - _ = pc.Close() - return nil, err - } - session := &gatewayRTCSession{ - nodeID: nodeID, - pc: pc, - dc: dc, - ready: make(chan struct{}), - pending: map[string]chan Response{}, - status: "connecting", - createdAt: time.Now().UTC(), - lastAttempt: time.Now().UTC(), - } - - pc.OnICECandidate(func(candidate *webrtc.ICECandidate) { - if candidate == nil { - return - } - sender := t.currentSignaler(nodeID) - if sender == nil { - return - } - _ = sender.Send(WireMessage{ - Type: "signal_candidate", - From: "gateway", - To: nodeID, - Session: nodeID, - Payload: structToMap(candidate.ToJSON()), - }) - }) - pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { - session.setStatus(strings.ToLower(strings.TrimSpace(state.String()))) - switch state { - case webrtc.PeerConnectionStateFailed, webrtc.PeerConnectionStateClosed, webrtc.PeerConnectionStateDisconnected: - session.setLastError(fmt.Errorf("peer connection state: %s", state.String())) - t.mu.Lock() - if t.sessions[nodeID] == session { - delete(t.sessions, nodeID) - } - t.mu.Unlock() - } - }) - dc.OnOpen(func() { - session.markReady() - }) - dc.OnError(func(err error) { - session.setStatus("channel_error") - session.setLastError(err) - }) - dc.OnMessage(func(message webrtc.DataChannelMessage) { - var msg WireMessage - if err := json.Unmarshal(message.Data, &msg); err != nil { - return - } - if strings.ToLower(strings.TrimSpace(msg.Type)) != "node_response" || msg.Response == nil { - return - } - session.mu.Lock() - respCh := session.pending[msg.ID] - if respCh != nil { - delete(session.pending, msg.ID) - } - session.mu.Unlock() - if respCh != nil { - respCh <- *msg.Response - } - }) - - offer, err := pc.CreateOffer(nil) - if err != nil { - _ = pc.Close() - return nil, err - } - if err := pc.SetLocalDescription(offer); err != nil { - _ = pc.Close() - return nil, err - } - - t.mu.Lock() - if existing := t.sessions[nodeID]; existing != nil { - t.mu.Unlock() - _ = pc.Close() - return existing, nil - } - t.sessions[nodeID] = session - t.mu.Unlock() - - sender := t.currentSignaler(nodeID) - if sender == nil { - t.mu.Lock() - delete(t.sessions, nodeID) - t.mu.Unlock() - session.setStatus("signal_unavailable") - session.setLastError(fmt.Errorf("node %s signaling unavailable", nodeID)) - _ = pc.Close() - return nil, fmt.Errorf("node %s signaling unavailable", nodeID) - } - if err := sender.Send(WireMessage{ - Type: "signal_offer", - From: "gateway", - To: nodeID, - Session: nodeID, - Payload: structToMap(*pc.LocalDescription()), - }); err != nil { - t.mu.Lock() - delete(t.sessions, nodeID) - t.mu.Unlock() - session.setStatus("signal_failed") - session.setLastError(err) - _ = pc.Close() - return nil, err - } - return session, nil -} - -func structToMap(v interface{}) map[string]interface{} { - b, _ := json.Marshal(v) - var out map[string]interface{} - _ = json.Unmarshal(b, &out) - if out == nil { - out = map[string]interface{}{} - } - return out -} - -func mapInto(in map[string]interface{}, out interface{}) error { - if len(in) == 0 { - return fmt.Errorf("empty payload") - } - b, err := json.Marshal(in) - if err != nil { - return err - } - return json.Unmarshal(b, out) -} diff --git a/pkg/tools/highlevel_arg_parsing_test.go b/pkg/tools/highlevel_arg_parsing_test.go deleted file mode 100644 index 9bc9a32..0000000 --- a/pkg/tools/highlevel_arg_parsing_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package tools - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/YspCoder/clawgo/pkg/nodes" - "github.com/YspCoder/clawgo/pkg/providers" -) - -func TestSessionsToolParsesStringArguments(t *testing.T) { - t.Parallel() - - tool := NewSessionsTool(func(limit int) []SessionInfo { - return []SessionInfo{ - {Key: "cron:1", Kind: "cron", UpdatedAt: time.Now()}, - {Key: "main:1", Kind: "main", UpdatedAt: time.Now()}, - } - }, func(key string, limit int) []providers.Message { return nil }) - - out, err := tool.Execute(context.Background(), map[string]interface{}{ - "action": "list", - "limit": "1", - "active_minutes": "60", - "kinds": "cron", - }) - if err != nil { - t.Fatalf("sessions execute failed: %v", err) - } - if !strings.Contains(out, "cron:1") || strings.Contains(out, "main:1") { - t.Fatalf("unexpected filtered output: %s", out) - } -} - -func TestNodesToolParsesStringDurationAndArtifactPaths(t *testing.T) { - t.Parallel() - - manager := nodes.NewManager() - manager.Upsert(nodes.NodeInfo{ - ID: "local", - Online: true, - Capabilities: nodes.Capabilities{ - Camera: true, - }, - }) - manager.RegisterHandler("local", func(req nodes.Request) nodes.Response { - return nodes.Response{ - OK: true, - Code: "ok", - Node: "local", - Action: req.Action, - Payload: map[string]interface{}{ - "artifacts": []map[string]interface{}{ - {"kind": "video", "path": "/tmp/demo.mp4"}, - }, - }, - } - }) - tool := NewNodesTool(manager, &nodes.Router{Relay: &nodes.HTTPRelayTransport{Manager: manager}}, "") - out, err := tool.Execute(context.Background(), map[string]interface{}{ - "action": "camera_clip", - "node": "local", - "duration_ms": "1000", - "artifact_paths": "memory/demo.md", - }) - if err != nil { - t.Fatalf("nodes execute failed: %v", err) - } - if !strings.Contains(out, `"ok":true`) { - t.Fatalf("unexpected output: %s", out) - } -} diff --git a/pkg/tools/nodes_tool.go b/pkg/tools/nodes_tool.go deleted file mode 100644 index fc86433..0000000 --- a/pkg/tools/nodes_tool.go +++ /dev/null @@ -1,255 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/YspCoder/clawgo/pkg/nodes" -) - -// NodesTool provides an OpenClaw-style control surface for paired nodes. -type NodesTool struct { - manager *nodes.Manager - router *nodes.Router - auditPath string -} - -const nodeAuditArtifactPreviewLimit = 32768 - -func NewNodesTool(m *nodes.Manager, r *nodes.Router, auditPath string) *NodesTool { - return &NodesTool{manager: m, router: r, auditPath: strings.TrimSpace(auditPath)} -} -func (t *NodesTool) Name() string { return "nodes" } -func (t *NodesTool) Description() string { - return "Manage paired nodes (status/describe/run/invoke/camera/screen/location/canvas)." -} -func (t *NodesTool) Parameters() map[string]interface{} { - return map[string]interface{}{"type": "object", "properties": map[string]interface{}{ - "action": map[string]interface{}{"type": "string", "description": "status|describe|run|invoke|agent_task|camera_snap|camera_clip|screen_record|screen_snapshot|location_get|canvas_snapshot|canvas_action"}, - "node": map[string]interface{}{"type": "string", "description": "target node id"}, - "mode": map[string]interface{}{"type": "string", "description": "auto|p2p|relay (default auto)"}, - "args": map[string]interface{}{"type": "object", "description": "action args"}, - "artifact_paths": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "optional workspace-relative file paths to bring back as artifacts for agent_task"}, - "task": map[string]interface{}{"type": "string", "description": "agent_task content for child node model"}, - "model": map[string]interface{}{"type": "string", "description": "optional model for agent_task"}, - "command": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "run command array shortcut"}, - "facing": map[string]interface{}{"type": "string", "description": "camera facing: front|back|both"}, - "duration_ms": map[string]interface{}{"type": "integer", "description": "clip/record duration"}, - }, "required": []string{"action"}} -} - -func (t *NodesTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { - _ = ctx - action := strings.TrimSpace(strings.ToLower(MapStringArg(args, "action"))) - if action == "" { - return "", fmt.Errorf("action is required") - } - nodeID := MapStringArg(args, "node") - mode := MapStringArg(args, "mode") - if t.manager == nil { - return "", fmt.Errorf("nodes manager not configured") - } - - switch action { - case "status", "describe": - if nodeID != "" { - n, ok := t.manager.Get(nodeID) - if !ok { - return "", fmt.Errorf("node not found: %s", nodeID) - } - b, _ := json.Marshal(n) - return string(b), nil - } - b, _ := json.Marshal(t.manager.List()) - return string(b), nil - default: - reqArgs := map[string]interface{}{} - if raw, ok := args["args"].(map[string]interface{}); ok { - for k, v := range raw { - reqArgs[k] = v - } - } - if rawPaths := MapStringListArg(args, "artifact_paths"); len(rawPaths) > 0 { - reqArgs["artifact_paths"] = rawPaths - } - if cmd, ok := args["command"].([]interface{}); ok && len(cmd) > 0 { - reqArgs["command"] = cmd - } - if facing := MapStringArg(args, "facing"); facing != "" { - f := strings.ToLower(strings.TrimSpace(facing)) - if f != "front" && f != "back" && f != "both" { - return "", fmt.Errorf("invalid_args: facing must be front|back|both") - } - reqArgs["facing"] = f - } - if di := MapIntArg(args, "duration_ms", 0); di > 0 { - if di <= 0 || di > 300000 { - return "", fmt.Errorf("invalid_args: duration_ms must be in 1..300000") - } - reqArgs["duration_ms"] = di - } - task := MapStringArg(args, "task") - model := MapStringArg(args, "model") - if action == "agent_task" && strings.TrimSpace(task) == "" { - return "", fmt.Errorf("invalid_args: agent_task requires task") - } - if action == "canvas_action" { - if act := MapStringArg(reqArgs, "action"); act == "" { - return "", fmt.Errorf("invalid_args: canvas_action requires args.action") - } - } - if nodeID == "" { - if picked, ok := t.manager.PickRequest(nodes.Request{Action: action, Task: task, Model: model, Args: reqArgs}, mode); ok { - nodeID = picked.ID - } - } - if nodeID == "" { - return "", fmt.Errorf("no eligible node found for action=%s", action) - } - req := nodes.Request{Action: action, Node: nodeID, Task: task, Model: model, Args: reqArgs} - if !t.manager.SupportsRequest(nodeID, req) { - return "", fmt.Errorf("node %s does not support action=%s", nodeID, action) - } - if t.router == nil { - return "", fmt.Errorf("nodes transport router not configured") - } - started := time.Now() - resp, err := t.router.Dispatch(ctx, req, mode) - durationMs := int(time.Since(started).Milliseconds()) - if err != nil { - t.writeAudit(req, nodes.Response{OK: false, Code: "transport_error", Error: err.Error(), Node: nodeID, Action: action}, mode, durationMs) - return "", err - } - t.writeAudit(req, resp, mode, durationMs) - b, _ := json.Marshal(resp) - return string(b), nil - } -} - -func (t *NodesTool) writeAudit(req nodes.Request, resp nodes.Response, mode string, durationMs int) { - if strings.TrimSpace(t.auditPath) == "" { - return - } - _ = os.MkdirAll(filepath.Dir(t.auditPath), 0755) - row := map[string]interface{}{ - "time": time.Now().UTC().Format(time.RFC3339), - "mode": mode, - "action": req.Action, - "node": req.Node, - "task": req.Task, - "model": req.Model, - "ok": resp.OK, - "code": resp.Code, - "error": resp.Error, - "duration_ms": durationMs, - } - if len(req.Args) > 0 { - row["request_args"] = req.Args - } - if used := MapStringArg(resp.Payload, "used_transport"); used != "" { - row["used_transport"] = used - } - if fallback := MapStringArg(resp.Payload, "fallback_from"); fallback != "" { - row["fallback_from"] = fallback - } - if count, kinds := artifactAuditSummary(resp.Payload["artifacts"]); count > 0 { - row["artifact_count"] = count - if len(kinds) > 0 { - row["artifact_kinds"] = kinds - } - if previews := artifactAuditPreviews(resp.Payload["artifacts"]); len(previews) > 0 { - row["artifacts"] = previews - } - } - b, _ := json.Marshal(row) - f, err := os.OpenFile(t.auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - _, _ = f.Write(append(b, '\n')) -} - -func artifactAuditSummary(raw interface{}) (int, []string) { - items, ok := raw.([]interface{}) - if !ok { - if typed, ok := raw.([]map[string]interface{}); ok { - items = make([]interface{}, 0, len(typed)) - for _, item := range typed { - items = append(items, item) - } - } - } - if len(items) == 0 { - return 0, nil - } - kinds := make([]string, 0, len(items)) - for _, item := range items { - row, ok := item.(map[string]interface{}) - if !ok { - continue - } - if kind := MapStringArg(row, "kind"); kind != "" { - kinds = append(kinds, kind) - } - } - return len(items), kinds -} - -func artifactAuditPreviews(raw interface{}) []map[string]interface{} { - items, ok := raw.([]interface{}) - if !ok { - if typed, ok := raw.([]map[string]interface{}); ok { - items = make([]interface{}, 0, len(typed)) - for _, item := range typed { - items = append(items, item) - } - } - } - if len(items) == 0 { - return nil - } - out := make([]map[string]interface{}, 0, len(items)) - for _, item := range items { - row, ok := item.(map[string]interface{}) - if !ok || len(row) == 0 { - continue - } - entry := map[string]interface{}{} - for _, key := range []string{"name", "kind", "mime_type", "storage", "path", "url", "source_path"} { - if value, ok := row[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" { - entry[key] = value - } - } - if size, ok := row["size_bytes"]; ok { - entry["size_bytes"] = size - } - if text := MapStringArg(row, "content_text"); text != "" { - entry["content_text"] = trimAuditContent(text) - } - if b64 := MapStringArg(row, "content_base64"); b64 != "" { - entry["content_base64"] = trimAuditContent(b64) - entry["content_base64_truncated"] = len(b64) > nodeAuditArtifactPreviewLimit - } - if truncated, ok := MapBoolArg(row, "truncated"); ok && truncated { - entry["truncated"] = true - } - if len(entry) > 0 { - out = append(out, entry) - } - } - return out -} - -func trimAuditContent(raw string) string { - raw = strings.TrimSpace(raw) - if len(raw) <= nodeAuditArtifactPreviewLimit { - return raw - } - return raw[:nodeAuditArtifactPreviewLimit] -} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index db61564..6854a48 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -21,7 +21,6 @@ type SubagentRun struct { Role string `json:"role"` AgentID string `json:"agent_id"` Transport string `json:"transport,omitempty"` - NodeID string `json:"node_id,omitempty"` ParentAgentID string `json:"parent_agent_id,omitempty"` NotifyMainPolicy string `json:"notify_main_policy,omitempty"` SessionKey string `json:"session_key"` @@ -168,7 +167,6 @@ func (sm *SubagentManager) spawnRun(ctx context.Context, opts SubagentSpawnOptio memoryNS := agentID systemPromptFile := "" transport := "local" - nodeID := "" parentAgentID := "" notifyMainPolicy := "final_only" toolAllowlist := []string(nil) @@ -201,7 +199,6 @@ func (sm *SubagentManager) spawnRun(ctx context.Context, opts SubagentSpawnOptio if transport == "" { transport = "local" } - nodeID = strings.TrimSpace(profile.NodeID) parentAgentID = strings.TrimSpace(profile.ParentAgentID) notifyMainPolicy = normalizeNotifyMainPolicy(profile.NotifyMainPolicy) systemPromptFile = strings.TrimSpace(profile.SystemPromptFile) @@ -279,7 +276,6 @@ func (sm *SubagentManager) spawnRun(ctx context.Context, opts SubagentSpawnOptio Role: role, AgentID: agentID, Transport: transport, - NodeID: nodeID, ParentAgentID: parentAgentID, NotifyMainPolicy: notifyMainPolicy, SessionKey: sessionKey, diff --git a/pkg/tools/subagent_profile.go b/pkg/tools/subagent_profile.go index 8dc04e4..16df5b0 100644 --- a/pkg/tools/subagent_profile.go +++ b/pkg/tools/subagent_profile.go @@ -12,7 +12,6 @@ import ( "time" "github.com/YspCoder/clawgo/pkg/config" - "github.com/YspCoder/clawgo/pkg/nodes" "github.com/YspCoder/clawgo/pkg/runtimecfg" ) @@ -20,7 +19,6 @@ type SubagentProfile struct { AgentID string `json:"agent_id"` Name string `json:"name"` Transport string `json:"transport,omitempty"` - NodeID string `json:"node_id,omitempty"` ParentAgentID string `json:"parent_agent_id,omitempty"` NotifyMainPolicy string `json:"notify_main_policy,omitempty"` Role string `json:"role,omitempty"` @@ -126,9 +124,6 @@ func (s *SubagentProfileStore) Upsert(profile SubagentProfile) (*SubagentProfile if managed, ok := s.configProfileLocked(p.AgentID); ok { return nil, fmt.Errorf("subagent profile %q is managed by %s", p.AgentID, managed.ManagedBy) } - if managed, ok := s.nodeProfileLocked(p.AgentID); ok { - return nil, fmt.Errorf("subagent profile %q is managed by %s", p.AgentID, managed.ManagedBy) - } now := time.Now().UnixMilli() path := s.profilePath(p.AgentID) @@ -167,9 +162,6 @@ func (s *SubagentProfileStore) Delete(agentID string) error { if managed, ok := s.configProfileLocked(id); ok { return fmt.Errorf("subagent profile %q is managed by %s", id, managed.ManagedBy) } - if managed, ok := s.nodeProfileLocked(id); ok { - return fmt.Errorf("subagent profile %q is managed by %s", id, managed.ManagedBy) - } err := os.Remove(s.profilePath(id)) if err != nil && !os.IsNotExist(err) { @@ -186,7 +178,6 @@ func normalizeSubagentProfile(in SubagentProfile) SubagentProfile { p.Name = p.AgentID } p.Transport = normalizeProfileTransport(p.Transport) - p.NodeID = strings.TrimSpace(p.NodeID) p.ParentAgentID = normalizeSubagentIdentifier(p.ParentAgentID) p.NotifyMainPolicy = normalizeNotifyMainPolicy(p.NotifyMainPolicy) p.Role = strings.TrimSpace(p.Role) @@ -277,12 +268,6 @@ func (s *SubagentProfileStore) mergedProfilesLocked() (map[string]SubagentProfil for _, p := range s.configProfilesLocked() { merged[p.AgentID] = p } - for _, p := range s.nodeProfilesLocked() { - if _, exists := merged[p.AgentID]; exists { - continue - } - merged[p.AgentID] = p - } fileProfiles, err := s.fileProfilesLocked() if err != nil { return nil, err @@ -356,31 +341,6 @@ func (s *SubagentProfileStore) configProfileLocked(agentID string) (SubagentProf return profileFromConfig(id, subcfg), true } -func (s *SubagentProfileStore) nodeProfileLocked(agentID string) (SubagentProfile, bool) { - id := normalizeSubagentIdentifier(agentID) - if id == "" { - return SubagentProfile{}, false - } - cfg := runtimecfg.Get() - parentAgentID := "main" - if cfg != nil { - if mainID := normalizeSubagentIdentifier(cfg.Agents.Router.MainAgentID); mainID != "" { - parentAgentID = mainID - } - } - for _, node := range nodes.DefaultManager().List() { - if isLocalNode(node.ID) { - continue - } - for _, profile := range profilesFromNode(node, parentAgentID) { - if profile.AgentID == id { - return profile, true - } - } - } - return SubagentProfile{}, false -} - func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentProfile { status := "active" if !subcfg.Enabled { @@ -390,7 +350,6 @@ func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentPro AgentID: agentID, Name: strings.TrimSpace(subcfg.DisplayName), Transport: strings.TrimSpace(subcfg.Transport), - NodeID: strings.TrimSpace(subcfg.NodeID), ParentAgentID: strings.TrimSpace(subcfg.ParentAgentID), NotifyMainPolicy: strings.TrimSpace(subcfg.NotifyMainPolicy), Role: strings.TrimSpace(subcfg.Role), @@ -407,111 +366,6 @@ func profileFromConfig(agentID string, subcfg config.SubagentConfig) SubagentPro }) } -func (s *SubagentProfileStore) nodeProfilesLocked() []SubagentProfile { - nodeItems := nodes.DefaultManager().List() - if len(nodeItems) == 0 { - return nil - } - cfg := runtimecfg.Get() - parentAgentID := "main" - if cfg != nil { - if mainID := normalizeSubagentIdentifier(cfg.Agents.Router.MainAgentID); mainID != "" { - parentAgentID = mainID - } - } - out := make([]SubagentProfile, 0, len(nodeItems)) - for _, node := range nodeItems { - if isLocalNode(node.ID) { - continue - } - profiles := profilesFromNode(node, parentAgentID) - for _, profile := range profiles { - if profile.AgentID == "" { - continue - } - out = append(out, profile) - } - } - return out -} - -func profilesFromNode(node nodes.NodeInfo, parentAgentID string) []SubagentProfile { - name := strings.TrimSpace(node.Name) - if name == "" { - name = strings.TrimSpace(node.ID) - } - status := "active" - if !node.Online { - status = "disabled" - } - rootAgentID := nodeBranchAgentID(node.ID) - if rootAgentID == "" { - return nil - } - out := []SubagentProfile{normalizeSubagentProfile(SubagentProfile{ - AgentID: rootAgentID, - Name: name + " Main Agent", - Transport: "node", - NodeID: strings.TrimSpace(node.ID), - ParentAgentID: parentAgentID, - Role: "remote_main", - MemoryNamespace: rootAgentID, - Status: status, - ManagedBy: "node_registry", - })} - for _, agent := range node.Agents { - agentID := normalizeSubagentIdentifier(agent.ID) - if agentID == "" || agentID == "main" { - continue - } - out = append(out, normalizeSubagentProfile(SubagentProfile{ - AgentID: nodeChildAgentID(node.ID, agentID), - Name: nodeChildAgentDisplayName(name, agent), - Transport: "node", - NodeID: strings.TrimSpace(node.ID), - ParentAgentID: rootAgentID, - Role: strings.TrimSpace(agent.Role), - MemoryNamespace: nodeChildAgentID(node.ID, agentID), - Status: status, - ManagedBy: "node_registry", - })) - } - return out -} - -func nodeBranchAgentID(nodeID string) string { - id := normalizeSubagentIdentifier(nodeID) - if id == "" { - return "" - } - return "node." + id + ".main" -} - -func nodeChildAgentID(nodeID, agentID string) string { - nodeID = normalizeSubagentIdentifier(nodeID) - agentID = normalizeSubagentIdentifier(agentID) - if nodeID == "" || agentID == "" { - return "" - } - return "node." + nodeID + "." + agentID -} - -func nodeChildAgentDisplayName(nodeName string, agent nodes.AgentInfo) string { - base := strings.TrimSpace(agent.DisplayName) - if base == "" { - base = strings.TrimSpace(agent.ID) - } - nodeName = strings.TrimSpace(nodeName) - if nodeName == "" { - return base - } - return nodeName + " / " + base -} - -func isLocalNode(nodeID string) bool { - return normalizeSubagentIdentifier(nodeID) == "local" -} - type SubagentProfileTool struct { store *SubagentProfileStore }