//go:build linux package agent import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/nezhahq/nezha/integration/agentcompat/internal/client" "github.com/nezhahq/nezha/integration/agentcompat/internal/dashboard" ) const readinessBudget = 45 * time.Second type Readiness struct { ServerID uint64 UUID string Version string Online bool LastActive time.Time VersionObserved bool RequestTaskEstablished bool StateReceiptObserved bool Host json.RawMessage State json.RawMessage } type serverListArguments struct { OnlineOnly bool `json:"online_only"` } type serverListResult struct { Servers []serverListItem `json:"servers"` Count int `json:"count"` } type serverListItem struct { ID uint64 `json:"id"` UUID string `json:"uuid"` Online bool `json:"online"` Platform string `json:"platform"` Arch string `json:"arch"` LastActive time.Time `json:"last_active"` } type serverGetArguments struct { ServerID uint64 `json:"server_id"` } type serverGetResult struct { ID uint64 `json:"id"` UUID string `json:"uuid"` Host json.RawMessage `json:"host"` State json.RawMessage `json:"state"` LastActive time.Time `json:"last_active"` } type execArguments struct { ServerID uint64 `json:"server_id"` Cmd string `json:"cmd"` Args []string `json:"args"` } type execResult struct { ExitCode int `json:"exit_code"` Stdout string `json:"stdout"` Error string `json:"error"` } func (agent *Agent) WaitReady(ctx context.Context, dashboardInstance *dashboard.Dashboard) (Readiness, error) { deadline, cancel := context.WithTimeout(ctx, readinessBudget) defer cancel() ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { readiness, err := agent.probeReadiness(deadline, dashboardInstance) if err == nil { return readiness, nil } select { case <-agent.supervisor.Exited(): return Readiness{}, fmt.Errorf("agent exited before readiness: %w", err) case <-deadline.Done(): return Readiness{}, fmt.Errorf("agent readiness: %w", errors.Join(err, deadline.Err())) case <-ticker.C: } } } func (agent *Agent) WaitReadyEventDriven(ctx context.Context, dashboardInstance *dashboard.Dashboard) (Readiness, error) { serverID, err := dashboardInstance.WaitForInfo2UUID(ctx, agent.uuid) if err != nil { return Readiness{}, fmt.Errorf("agent info2 readiness: %w", err) } readiness, err := agent.probeReadinessForServer(ctx, dashboardInstance.Clients().MCP, serverID) if err != nil { return Readiness{}, err } return readiness, nil } func (agent *Agent) WaitReadyEventDrivenWithClient(ctx context.Context, dashboardInstance *dashboard.Dashboard, mcpClient *client.Client) (Readiness, error) { serverID, err := dashboardInstance.WaitForInfo2UUID(ctx, agent.uuid) if err != nil { return Readiness{}, fmt.Errorf("agent info2 readiness: %w", err) } return agent.probeReadinessForServer(ctx, mcpClient, serverID) } func (agent *Agent) probeReadinessForServer(ctx context.Context, mcpClient *client.Client, serverID uint64) (Readiness, error) { serverResponse, err := client.CallTool[serverGetArguments, serverGetResult](ctx, mcpClient, client.ToolCall[serverGetArguments]{Name: "server.get", Arguments: serverGetArguments{ServerID: serverID}}) if err != nil { return Readiness{}, err } server := serverListItem{ID: serverID, UUID: agent.uuid, Online: true} if err := verifyServerGetResult(server, serverResponse.StructuredContent); err != nil { return Readiness{}, err } execResponse, err := client.CallTool[execArguments, execResult](ctx, mcpClient, client.ToolCall[execArguments]{Name: "server.exec", Arguments: execArguments{ServerID: serverID, Cmd: "sh", Args: []string{"-c", "printf agentcompat-ready"}}}) if err != nil { return Readiness{}, fmt.Errorf("live RequestTask probe: %w", err) } if execResponse.StructuredContent.ExitCode != 0 || execResponse.StructuredContent.Stdout != "agentcompat-ready" { return Readiness{}, errors.New("live RequestTask probe returned unexpected result") } version, versionObserved, err := decodeHostVersionEvidence(serverResponse.StructuredContent.Host) if err != nil { return Readiness{}, err } return Readiness{ServerID: serverID, UUID: agent.uuid, Version: version, Online: true, LastActive: serverResponse.StructuredContent.LastActive, VersionObserved: versionObserved, RequestTaskEstablished: true, StateReceiptObserved: true, Host: serverResponse.StructuredContent.Host, State: serverResponse.StructuredContent.State}, nil } func (agent *Agent) probeReadiness(ctx context.Context, dashboardInstance *dashboard.Dashboard) (Readiness, error) { call := dashboardInstance.Clients().MCP list, err := client.CallTool[serverListArguments, serverListResult](ctx, call, client.ToolCall[serverListArguments]{Name: "server.list", Arguments: serverListArguments{OnlineOnly: true}}) if err != nil { return Readiness{}, err } var server serverListItem for _, candidate := range list.StructuredContent.Servers { if candidate.UUID == agent.uuid { server = candidate break } } if server.ID == 0 || server.UUID != agent.uuid || !server.Online { return Readiness{}, errors.New("agent UUID is not online in dashboard server.list") } execResponse, err := client.CallTool[execArguments, execResult](ctx, call, client.ToolCall[execArguments]{Name: "server.exec", Arguments: execArguments{ServerID: server.ID, Cmd: "sh", Args: []string{"-c", "printf agentcompat-ready"}}}) if err != nil { return Readiness{}, fmt.Errorf("live RequestTask probe: %w", err) } if execResponse.StructuredContent.ExitCode != 0 || execResponse.StructuredContent.Stdout != "agentcompat-ready" { return Readiness{}, errors.New("live RequestTask probe returned unexpected result") } serverResponse, err := client.CallTool[serverGetArguments, serverGetResult](ctx, call, client.ToolCall[serverGetArguments]{Name: "server.get", Arguments: serverGetArguments{ServerID: server.ID}}) if err != nil { return Readiness{}, err } if err := verifyServerGetResult(server, serverResponse.StructuredContent); err != nil { return Readiness{}, err } version, versionObserved, err := decodeHostVersionEvidence(serverResponse.StructuredContent.Host) if err != nil { return Readiness{}, err } stateReceiptObserved := dashboardInstance.ReceiptAccepted() if !dashboardInstance.ReceiptGateEnabled() { stateReceiptObserved = agent.observeStateReceipt(serverResponse.StructuredContent.LastActive) } if !stateReceiptObserved { return Readiness{}, errors.New("waiting for a second state report after receipt") } return Readiness{ ServerID: server.ID, UUID: agent.uuid, Version: version, Online: true, LastActive: serverResponse.StructuredContent.LastActive, VersionObserved: versionObserved, RequestTaskEstablished: true, StateReceiptObserved: stateReceiptObserved, Host: serverResponse.StructuredContent.Host, State: serverResponse.StructuredContent.State, }, nil } func decodeHostVersionEvidence(raw json.RawMessage) (string, bool, error) { var host struct { Version string `json:"version"` } if err := json.Unmarshal(raw, &host); err != nil { return "", false, fmt.Errorf("decode dashboard Host: %w", err) } // A decoded Host object is not version evidence unless the Agent reported a value. return host.Version, host.Version != "", nil } func verifyServerGetResult(server serverListItem, result serverGetResult) error { if server.ID == 0 || result.ID != server.ID || result.UUID != server.UUID { return errors.New("dashboard server.get identity does not match server.list") } if len(result.Host) == 0 || len(result.State) == 0 || string(result.Host) == "null" || string(result.State) == "null" { return errors.New("dashboard server.get omitted Host or State") } return nil } func (agent *Agent) observeStateReceipt(lastActive time.Time) bool { agent.readinessMu.Lock() defer agent.readinessMu.Unlock() observed := !agent.lastStateReport.IsZero() && lastActive.After(agent.lastStateReport) if lastActive.After(agent.lastStateReport) { agent.lastStateReport = lastActive } return observed } func (agent *Agent) AssertNeverOnline(ctx context.Context, dashboardInstance *dashboard.Dashboard, duration time.Duration) error { deadline, cancel := context.WithTimeout(ctx, duration) defer cancel() ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() var lastError error for { list, err := client.CallTool[serverListArguments, serverListResult](deadline, dashboardInstance.Clients().MCP, client.ToolCall[serverListArguments]{Name: "server.list", Arguments: serverListArguments{OnlineOnly: true}}) if err == nil { for _, server := range list.StructuredContent.Servers { if server.UUID == agent.uuid { return errors.New("invalid-secret agent became online") } } } else if deadline.Err() == nil { lastError = err } select { case <-deadline.Done(): if lastError != nil { return fmt.Errorf("server.list unavailable while asserting agent stayed offline: %w", lastError) } return nil case <-ticker.C: } } }