diff --git a/docs/CONNECTION_ARCHITECTURE.md b/docs/CONNECTION_ARCHITECTURE.md index 37dd1c27c..63629b340 100644 --- a/docs/CONNECTION_ARCHITECTURE.md +++ b/docs/CONNECTION_ARCHITECTURE.md @@ -118,7 +118,10 @@ Idle → Connecting → Connected | Connected | Error/Rejected | Degraded | | Connected | PairingRequired | PairingRequired | | Connected | Connecting | Connecting | -| Connected | Disabled/Off | Connected | +| Connected | Idle while Node mode is intended | Degraded | +| Connected | Disabled/Off | Ready | + +`GatewayConnectionSnapshot.NodeConnectionIntended` records the Node mode intent used by the manager's state machine. If Node mode is enabled but node startup is skipped, blocked, or missing a node credential, the manager publishes a blocked node snapshot (`NodeState=Error`, `NodeError=...`) instead of leaving the node idle and letting tray surfaces report a healthy connection. ## Gateway registry and persistence diff --git a/docs/MCP_MODE.md b/docs/MCP_MODE.md index 187f0262e..a93ea0037 100644 --- a/docs/MCP_MODE.md +++ b/docs/MCP_MODE.md @@ -111,6 +111,8 @@ Settings UI exposes both toggles in the Advanced section, with the live MCP endp A legacy `McpOnlyMode` field is migrated automatically on load and never re-written. +MCP startup is reported from the actual listener state. `NodeService.McpStartupError` is populated when capability registration or the HTTP listener fails, and MCP-only startup is not treated as successful unless the loopback MCP server is running. Tray, Permissions, and Command Center surfaces show local MCP-only separately from gateway connectivity so a working local MCP listener is never presented as a gateway connection. + ## Why this matters ### Testing diff --git a/src/OpenClaw.Connection/ConnectionStateMachine.cs b/src/OpenClaw.Connection/ConnectionStateMachine.cs index 7613e6122..a4b709b70 100644 --- a/src/OpenClaw.Connection/ConnectionStateMachine.cs +++ b/src/OpenClaw.Connection/ConnectionStateMachine.cs @@ -123,9 +123,16 @@ public void SetNodeEnabled(bool enabled) { _nodeEnabled = enabled; if (!enabled) + { _nodeState = RoleConnectionState.Disabled; + _nodeError = null; + _nodeCredentialSource = null; + } else if (_nodeState == RoleConnectionState.Disabled) + { _nodeState = RoleConnectionState.Idle; + _nodeError = null; + } RebuildSnapshot(); } @@ -199,6 +206,15 @@ internal void SetNodeCredentialSource(string? source) RebuildSnapshot(); } + internal void BlockNodeStart(string detail) + { + _nodeEnabled = true; + _nodeState = RoleConnectionState.Error; + _nodeError = detail; + _nodeCredentialSource = null; + RebuildSnapshot(); + } + /// Update the operator pairing request ID in the snapshot. internal void SetOperatorPairingRequestId(string? requestId) { @@ -304,6 +320,7 @@ private void ApplyTransition(ConnectionTrigger trigger, string? detail) case ConnectionTrigger.NodePairingRequired: _nodeState = RoleConnectionState.PairingRequired; + _nodeError = null; break; case ConnectionTrigger.NodePaired: @@ -340,6 +357,7 @@ private void RebuildSnapshot() // Clear requestId when no longer in PairingRequired to prevent stale reads OperatorPairingRequestId = _operatorState == RoleConnectionState.PairingRequired ? Current.OperatorPairingRequestId : null, + NodeConnectionIntended = _nodeEnabled, NodeState = _nodeState, NodeError = _nodeError, NodeCredentialSource = _nodeCredentialSource, diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index a4792c1fc..b68179a36 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -51,6 +51,17 @@ public sealed class GatewayConnectionManager : IGatewayConnectionManager private bool _activeConnectUsedBootstrapToken; private bool _postBootstrapOperatorReconnectScheduled; + private const string MissingNodeCredentialMessage = + "No node credential available. Re-pair this PC or add a shared/bootstrap gateway token."; + private const string MissingNodeConnectorMessage = + "Node mode is enabled, but no node connector is configured."; + private const string MissingActiveGatewayForNodeMessage = + "Node mode is enabled, but there is no active gateway context for node startup."; + private const string MissingGatewayRecordForNodeMessage = + "Node mode is enabled, but the active gateway record could not be found."; + private const string NodeTunnelStartFailedMessage = + "Node mode is enabled, but the SSH tunnel for node startup could not be started."; + public event EventHandler? StateChanged; public event EventHandler? DiagnosticEvent; public event EventHandler? OperatorClientChanged; @@ -119,7 +130,6 @@ public async Task ConnectAsync(string? gatewayId = null) public async Task ConnectNodeOnlyAsync(string? gatewayId = null) { ThrowIfDisposed(); - var prevState = _stateMachine.Current.OverallState; long? preparedGeneration = null; await _transitionSemaphore.WaitAsync(); @@ -137,7 +147,7 @@ public async Task ConnectNodeOnlyAsync(string? gatewayId = null) var startedGeneration = await StartNodeConnectionAsync(preparedGeneration.Value); if (startedGeneration.HasValue) - EmitStateChanged(prevState); + EmitStateChanged(); } /// Core connect logic. Caller must hold . @@ -202,16 +212,16 @@ private async Task ConnectCoreAsync(string? gatewayId = null) _activeGatewayRecordId = record.Id; _activeSshTunnel = record.SshTunnel; _gatewayNeedsV2Signature = record.IsLocal || record.RequiresV2Signature; + SyncNodeIntentFromSettings(); if (credential == null) { _logger.Warn("[ConnMgr] No credential available for gateway"); - var prev = _stateMachine.Current.OverallState; // Must go through Connecting → Error since AuthenticationFailed requires Connecting state _stateMachine.TryTransition(ConnectionTrigger.ConnectRequested); _stateMachine.SetOperatorCredentialSource(null); _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, "No credential available"); - EmitStateChanged(prev); + EmitStateChanged(); return; } @@ -220,7 +230,7 @@ private async Task ConnectCoreAsync(string? gatewayId = null) _stateMachine.TryTransition(ConnectionTrigger.ConnectRequested); _stateMachine.SetOperatorCredentialSource(credential.Source); _diagnostics.RecordStateChange(prevState, _stateMachine.Current.OverallState); - EmitStateChanged(prevState); + EmitStateChanged(); // Create client via factory — use a diagnostic-tee logger so client handshake // logs appear in the Connection Status window timeline. @@ -235,9 +245,8 @@ tunnel.SshPort is < 1 or > 65535 || { _logger.Warn("[ConnMgr] SSH tunnel config is incomplete"); _diagnostics.Record("tunnel", "SSH tunnel config is incomplete"); - var p = _stateMachine.Current.OverallState; _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, "SSH tunnel config is incomplete"); - EmitStateChanged(p); + EmitStateChanged(); return; } try @@ -249,9 +258,8 @@ tunnel.SshPort is < 1 or > 65535 || { _logger.Error($"[ConnMgr] SSH tunnel start failed: {ex.Message}"); _diagnostics.Record("tunnel", "SSH tunnel start failed", ex.Message); - var p = _stateMachine.Current.OverallState; _stateMachine.TryTransition(ConnectionTrigger.WebSocketError, $"SSH tunnel failed: {ex.Message}"); - EmitStateChanged(p); + EmitStateChanged(); return; } } @@ -356,14 +364,6 @@ tunnel.SshPort is < 1 or > 65535 || if (!Directory.Exists(perGatewayIdentityDir)) Directory.CreateDirectory(perGatewayIdentityDir); - var nodeCredential = _credentialResolver.ResolveNode(record, perGatewayIdentityDir); - if (nodeCredential == null) - { - _logger.Warn("[ConnMgr] No node credential available for node-only connect"); - _diagnostics.Record("node", "No node credential available for node-only connect"); - return null; - } - // Same-gateway node reapproval reconnects keep the operator alive so it can // request the post-handshake node.list; all other paths reset lifecycle/tunnel state. var preservesOperatorConnection = @@ -396,15 +396,31 @@ tunnel.SshPort is < 1 or > 65535 || GatewayUrl = record.Url, GatewayName = record.FriendlyName }; + _stateMachine.SetNodeEnabled(true); + _stateMachine.StartNodeConnecting(); + _stateMachine.SetNodeCredentialSource(null); + + var nodeCredential = _credentialResolver.ResolveNode(record, perGatewayIdentityDir); + if (nodeCredential == null) + { + _logger.Warn("[ConnMgr] No node credential available for node-only connect"); + _diagnostics.Record("node", "No node credential available for node-only connect"); + _stateMachine.BlockNodeStart(MissingNodeCredentialMessage); + EmitStateChanged(); + return null; + } _diagnostics.RecordCredentialResolution(nodeCredential); _stateMachine.SetOperatorCredentialSource(operatorCredentialSource); - _stateMachine.SetNodeCredentialSource(nodeCredential.Source); _diagnostics.Record("node", $"Starting node-only connection to {record.Url}", $"Credential source: {nodeCredential.Source}"); if (!preservesOperatorConnection && !await TryStartTunnelForNodeOnlyAsync(record)) + { + _stateMachine.BlockNodeStart(NodeTunnelStartFailedMessage); + EmitStateChanged(); return null; + } return Interlocked.Read(ref _generation) == gen ? gen : null; } @@ -469,9 +485,10 @@ private async Task DisconnectCoreAsync() var prev = _stateMachine.Current.OverallState; await DisposeActiveClientAsync(); + SyncNodeIntentFromSettings(); _stateMachine.TryTransition(ConnectionTrigger.DisconnectRequested); _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); - EmitStateChanged(prev); + EmitStateChanged(); } public async Task ReconnectAsync() @@ -703,7 +720,6 @@ private async Task HandleOperatorStatusChangedAsync(ConnectionStatus status, lon { if (Interlocked.Read(ref _generation) != gen) return; - var prev = _stateMachine.Current.OverallState; switch (status) { case ConnectionStatus.Connected: @@ -725,7 +741,7 @@ private async Task HandleOperatorStatusChangedAsync(ConnectionStatus status, lon _diagnostics.RecordWebSocketEvent("WebSocket connecting"); break; } - EmitStateChanged(prev); + EmitStateChanged(); } finally { @@ -743,10 +759,9 @@ private async Task HandleAuthenticationFailedAsync(string message, long gen) if (TryScheduleOperatorTokenRecovery(message, gen)) return; - var prev = _stateMachine.Current.OverallState; _diagnostics.Record("error", "Authentication failed", message); _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, message); - EmitStateChanged(prev); + EmitStateChanged(); } finally { @@ -785,6 +800,10 @@ private static bool IsOperatorDeviceTokenMismatch(string message) => private async Task HandleHandshakeSucceededAsync(long gen) { + bool shouldStartNodeConnection = false; + bool missingGatewayRecordForNode = false; + bool missingActiveGatewayForNode = false; + bool missingNodeConnector = false; await _transitionSemaphore.WaitAsync(); try { @@ -793,7 +812,7 @@ private async Task HandleHandshakeSucceededAsync(long gen) var prev = _stateMachine.Current.OverallState; _diagnostics.Record("state", "Handshake succeeded (hello-ok)"); _stateMachine.TryTransition(ConnectionTrigger.HandshakeSucceeded); - _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); + var nodeModeIntended = SyncNodeIntentFromSettings(); if (_operatorTokenRecoveryAttemptedGatewayId == _activeGatewayRecordId) _operatorTokenRecoveryAttemptedGatewayId = null; @@ -803,7 +822,43 @@ private async Task HandleHandshakeSucceededAsync(long gen) _stateMachine.SetOperatorDeviceId(client.OperatorDeviceId); } - EmitStateChanged(prev); + missingActiveGatewayForNode = + nodeModeIntended && + (_activeGatewayRecordId == null || _activeIdentityPath == null); + missingGatewayRecordForNode = + nodeModeIntended && + !missingActiveGatewayForNode && + _activeGatewayRecordId != null && + _registry.GetById(_activeGatewayRecordId) == null; + shouldStartNodeConnection = + !missingActiveGatewayForNode && + !missingGatewayRecordForNode && + ShouldStartNodeConnection(); + missingNodeConnector = shouldStartNodeConnection && _nodeConnector == null; + if (missingActiveGatewayForNode) + { + _stateMachine.BlockNodeStart(MissingActiveGatewayForNodeMessage); + } + else if (missingGatewayRecordForNode) + { + _stateMachine.BlockNodeStart(MissingGatewayRecordForNodeMessage); + } + else if (missingNodeConnector) + { + _stateMachine.BlockNodeStart(MissingNodeConnectorMessage); + } + else if (shouldStartNodeConnection) + { + _stateMachine.SetNodeEnabled(true); + if (_nodeConnector != null) + { + _stateMachine.StartNodeConnecting(); + _stateMachine.SetNodeCredentialSource(null); + } + } + + _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); + EmitStateChanged(); // Stamp LastConnected so auto-reconnect on next startup can use this gateway. // Uses the atomic Update helper to avoid overwriting concurrent registry changes. @@ -826,10 +881,18 @@ private async Task HandleHandshakeSucceededAsync(long gen) _transitionSemaphore.Release(); } - // Start node connection outside the semaphore to avoid deadlocks - if (_nodeConnector != null && ShouldStartNodeConnection()) + // Start node connection outside the semaphore to avoid deadlocks. + // If Node mode is intended but no connector exists, publish the blocker + // through the same manager snapshot instead of leaving node Idle/healthy. + if (missingActiveGatewayForNode || missingGatewayRecordForNode || missingNodeConnector) { - await StartNodeConnectionAsync(gen); + return; + } + + if (shouldStartNodeConnection) + { + if (_nodeConnector != null) + await StartNodeConnectionAsync(gen); } } @@ -972,7 +1035,7 @@ private async Task HandlePairingRequiredAsync(string? requestId, long gen) // Store requestId in snapshot so setup flows can use it for explicit approval _stateMachine.SetOperatorPairingRequestId(requestId); _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); - EmitStateChanged(prev); + EmitStateChanged(); } finally { @@ -1095,6 +1158,18 @@ private bool ShouldStartNodeConnection() return _isNodeEnabled?.Invoke() ?? false; } + private bool SyncNodeIntentFromSettings() + { + var enabled = _isNodeEnabled?.Invoke() ?? false; + if (_stateMachine.Current.NodeConnectionIntended != enabled || + (!enabled && _stateMachine.Current.NodeState != RoleConnectionState.Disabled)) + { + _stateMachine.SetNodeEnabled(enabled); + } + + return enabled; + } + private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration) => !_disposed && Interlocked.Read(ref _generation) == lifecycleGeneration && @@ -1104,9 +1179,11 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration) long expectedLifecycleGeneration, long? expectedNodeGeneration = null) { - CancellationTokenSource nodeOperationCts; - CancellationToken nodeOperationToken; - long nodeGeneration; + CancellationTokenSource? nodeOperationCts = null; + CancellationToken nodeOperationToken = CancellationToken.None; + long nodeGeneration = 0; + string? preStartBlocker = null; + CancellationToken preStartBlockerToken = CancellationToken.None; await _nodeStartSemaphore.WaitAsync(); try @@ -1132,26 +1209,31 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration) "Previous node disconnect")) { _diagnostics.Record("node", "Previous node disconnect timed out"); - return null; + preStartBlocker = "Previous node disconnect timed out"; + preStartBlockerToken = _operationCts?.Token ?? CancellationToken.None; } } catch (Exception ex) { _logger.Error($"[ConnMgr] Previous node disconnect failed: {ex.Message}"); _diagnostics.Record("node", "Previous node disconnect failed", ex.Message); - return null; + preStartBlocker = $"Previous node disconnect failed: {ex.Message}"; + preStartBlockerToken = _operationCts?.Token ?? CancellationToken.None; } } - lock (_nodeOperationLock) + if (preStartBlocker == null) { - if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, expectedNodeGeneration)) - return null; + lock (_nodeOperationLock) + { + if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, expectedNodeGeneration)) + return null; - nodeOperationCts = new CancellationTokenSource(); - nodeOperationToken = nodeOperationCts.Token; - nodeGeneration = Interlocked.Increment(ref _nodeConnectionGeneration); - _nodeOperationCts = nodeOperationCts; + nodeOperationCts = new CancellationTokenSource(); + nodeOperationToken = nodeOperationCts.Token; + nodeGeneration = Interlocked.Increment(ref _nodeConnectionGeneration); + _nodeOperationCts = nodeOperationCts; + } } } finally @@ -1159,9 +1241,19 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration) _nodeStartSemaphore.Release(); } + if (preStartBlocker != null) + { + await BlockNodeStartAsync( + preStartBlocker, + preStartBlockerToken, + expectedLifecycleGeneration, + expectedNodeGeneration); + return null; + } + try { - return await StartNodeConnectionCoreAsync(nodeGeneration, nodeOperationToken) + return await StartNodeConnectionCoreAsync(expectedLifecycleGeneration, nodeGeneration, nodeOperationToken) ? nodeGeneration : null; } @@ -1176,7 +1268,7 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration) if (ReferenceEquals(_nodeOperationCts, nodeOperationCts)) _nodeOperationCts = null; } - nodeOperationCts.Dispose(); + nodeOperationCts!.Dispose(); } } @@ -1188,7 +1280,50 @@ private bool IsExpectedNodeStartCurrent( (!expectedNodeGeneration.HasValue || Interlocked.Read(ref _nodeConnectionGeneration) == expectedNodeGeneration.Value); + private async Task BlockNodeStartAsync( + string detail, + CancellationToken cancellationToken, + long? expectedLifecycleGeneration = null, + long? expectedNodeGeneration = null) + { + if (expectedLifecycleGeneration.HasValue && + Interlocked.Read(ref _generation) != expectedLifecycleGeneration.Value) + { + return; + } + + if (expectedNodeGeneration.HasValue && + Interlocked.Read(ref _nodeConnectionGeneration) != expectedNodeGeneration.Value) + { + return; + } + + await _transitionSemaphore.WaitAsync(cancellationToken); + try + { + if (expectedLifecycleGeneration.HasValue && + Interlocked.Read(ref _generation) != expectedLifecycleGeneration.Value) + { + return; + } + + if (expectedNodeGeneration.HasValue && + Interlocked.Read(ref _nodeConnectionGeneration) != expectedNodeGeneration.Value) + { + return; + } + + _stateMachine.BlockNodeStart(detail); + EmitStateChanged(); + } + finally + { + _transitionSemaphore.Release(); + } + } + private async Task StartNodeConnectionCoreAsync( + long expectedLifecycleGeneration, long nodeGeneration, CancellationToken cancellationToken) { @@ -1198,30 +1333,63 @@ private async Task StartNodeConnectionCoreAsync( return false; } - if (_nodeConnector == null || _activeGatewayRecordId == null || _activeIdentityPath == null) return false; + if (_nodeConnector == null) + { + await BlockNodeStartAsync(MissingNodeConnectorMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration); + return false; + } + + if (_activeGatewayRecordId == null || _activeIdentityPath == null) + { + await BlockNodeStartAsync(MissingActiveGatewayForNodeMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration); + return false; + } var record = _registry.GetById(_activeGatewayRecordId); if (record == null) { _logger.Warn("[ConnMgr] Cannot start node — gateway record not found"); + await BlockNodeStartAsync(MissingGatewayRecordForNodeMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration); return false; } + // Mark node as enabled in the state machine so UI reflects node state + // before credential resolution can fail. Otherwise node mode could look + // healthy even though the intended node never started. + await _transitionSemaphore.WaitAsync(cancellationToken); + try + { + if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, nodeGeneration)) + return false; + + var before = _stateMachine.Current; + _stateMachine.SetNodeEnabled(true); + _stateMachine.StartNodeConnecting(); + _stateMachine.SetNodeCredentialSource(null); + if (_stateMachine.Current != before) + EmitStateChanged(); + } + finally + { + _transitionSemaphore.Release(); + } + // Use root identity path — clients always read/write from root, not per-gateway var nodeCredential = _credentialResolver.ResolveNode(record, _activeIdentityPath!); if (nodeCredential == null) { _logger.Warn("[ConnMgr] No node credential available — skipping node connection"); _diagnostics.Record("node", "No node credential available"); + await BlockNodeStartAsync(MissingNodeCredentialMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration); return false; } - // Mark node as enabled in the state machine so UI reflects node state - // State machine is not thread-safe — acquire semaphore for mutation await _transitionSemaphore.WaitAsync(cancellationToken); try { - _stateMachine.SetNodeEnabled(true); + if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, nodeGeneration)) + return false; + _stateMachine.SetNodeCredentialSource(nodeCredential.Source); } finally @@ -1262,6 +1430,12 @@ await _nodeConnector.ConnectAsync(nodeConnectUrl, nodeCredential, _activeIdentit _logger.Error($"[ConnMgr] Node connect failed: {ex.Message}"); _diagnostics.Record("node", "Node connect failed", ex.Message); + await BlockNodeStartAsync( + $"Node connect failed: {ex.Message}", + cancellationToken, + expectedLifecycleGeneration, + nodeGeneration); + return false; } return !cancellationToken.IsCancellationRequested && @@ -1297,7 +1471,6 @@ private async Task OnNodeStatusChangedAsync(ConnectionStatus status) await _transitionSemaphore.WaitAsync(); try { - var prev = _stateMachine.Current.OverallState; switch (status) { case ConnectionStatus.Connected: @@ -1336,7 +1509,7 @@ private async Task OnNodeStatusChangedAsync(ConnectionStatus status) } TryClearBootstrapTokenAfterDurablePairing(); - EmitStateChanged(prev); + EmitStateChanged(); } finally { @@ -1371,7 +1544,6 @@ private async Task OnNodePairingStatusChangedAsync( if (!IsCurrentNodeAttempt(lifecycleGeneration, nodeGeneration)) return; - var prev = _stateMachine.Current.OverallState; switch (e.Status) { case PairingStatus.Paired: @@ -1404,7 +1576,7 @@ private async Task OnNodePairingStatusChangedAsync( } TryClearBootstrapTokenAfterDurablePairing(); - EmitStateChanged(prev); + EmitStateChanged(); } finally { @@ -1686,7 +1858,7 @@ private static string BuildDeviceAutoApprovalFailureDetail(IReadOnlyList // ─── Helpers ─── - private void EmitStateChanged(OverallConnectionState previousOverall) + private void EmitStateChanged() { var snapshot = _stateMachine.Current; // Always fire when any part of the snapshot changed — not just OverallState. diff --git a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs index 28fd05879..b06cec57d 100644 --- a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs +++ b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs @@ -22,6 +22,7 @@ public sealed record GatewayConnectionSnapshot public string? OperatorPairingRequestId { get; init; } // ─── Node ─── + public bool NodeConnectionIntended { get; init; } public RoleConnectionState NodeState { get; init; } public string? NodeError { get; init; } public OpenClaw.Shared.PairingStatus NodePairingStatus { get; init; } @@ -67,10 +68,26 @@ public static OverallConnectionState DeriveOverall( if (op == RoleConnectionState.Connecting) return OverallConnectionState.Connecting; + if (op != RoleConnectionState.Connected && nodeEnabled) + { + if (node == RoleConnectionState.PairingRequired) + return OverallConnectionState.PairingRequired; + + if (node == RoleConnectionState.Connecting) + return OverallConnectionState.Connecting; + + if (node == RoleConnectionState.Connected) + return OverallConnectionState.Connected; + + if (node is RoleConnectionState.Error or RoleConnectionState.PairingRejected or RoleConnectionState.RateLimited) + return OverallConnectionState.Error; + } + // From here, operator is Connected. if (op == RoleConnectionState.Connected && nodeEnabled && (node == RoleConnectionState.Error || + node == RoleConnectionState.Idle || node == RoleConnectionState.PairingRejected || node == RoleConnectionState.RateLimited)) return OverallConnectionState.Degraded; diff --git a/src/OpenClaw.Shared/Capabilities/AppCapability.cs b/src/OpenClaw.Shared/Capabilities/AppCapability.cs index ed4f45926..0746523c3 100644 --- a/src/OpenClaw.Shared/Capabilities/AppCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/AppCapability.cs @@ -144,7 +144,34 @@ private NodeInvokeResponse HandleSettingsSet(NodeInvokeRequest request) return Error("Missing required arg: value"); if (SettingsSetHandler == null) return Error("Settings handler not registered"); - return Success(SettingsSetHandler(name, value)); + + var result = SettingsSetHandler(name, value); + if (TryGetErrorPayload(result, out var error)) + return Error(error); + + return Success(result); + } + + private static bool TryGetErrorPayload(object? result, out string error) + { + error = ""; + if (result == null) + return false; + + var property = result.GetType().GetProperty( + "error", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.IgnoreCase); + + if (property?.GetValue(result) is not string message || + string.IsNullOrWhiteSpace(message)) + { + return false; + } + + error = message; + return true; } private NodeInvokeResponse HandleMenu() diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs index ab9bf4b72..12f9eae31 100644 --- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs +++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs @@ -255,7 +255,7 @@ private object HandleToolsList() ["app.navigate"] = "Navigate the companion app to a specific page (e.g., 'home', 'sessions', 'settings'). Args: page (string, required). Returns { navigated, page }.", ["app.status"] = - "Get current connection status, node state, and gateway info. Returns { connectionStatus, nodeConnected, nodePaired, nodePendingApproval, gatewayVersion, sessionCount, nodeCount }.", + "Get current connection status, manager-owned overall/operator/node state, and gateway info. Returns { connectionStatus, overallState, operatorState, nodeState, nodeConnected, nodePaired, nodePendingApproval, nodeError, gatewayVersion, sessionCount, nodeCount }.", ["app.sessions"] = "List active sessions with optional agent filter. Args: agentId (string, optional). Returns array of { Key, Status, Model, AgeText, tokens }.", ["app.agents"] = @@ -267,9 +267,9 @@ private object HandleToolsList() ["app.settings.get"] = "Read a local app setting by name. Args: name (string, required). Returns the setting value.", ["app.settings.set"] = - "Set a local app setting (name and value). Args: name (string, required), value (string, required). Returns { name, value }.", + "Set a local app setting (name and value), persist it, and apply the same reconnect/reload behavior as saving settings in the app UI. Args: name (string, required), value (string, required). Returns { name, value }; runtime apply failures surface as tool errors.", ["app.menu"] = - "Get tray menu state (status, session count, node count). Returns array of menu items.", + "Get tray menu state (status including overallState/nodeState/nodeError, session count, node count). Returns array of menu items.", ["app.search"] = "Search the command palette and return matching commands. Args: query (string, required). Returns array of { Title, Subtitle, Icon }.", ["app.dashboard.url"] = diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index d2cc98ce3..ff15d7ed2 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -47,17 +47,25 @@ private void WireAppCapabilityHandlers() return await tcs.Task; }; - app.StatusHandler = () => new + app.StatusHandler = () => { - connectionStatus = _appState!.Status.ToString(), - nodeConnected = _nodeService?.IsConnected ?? false, - nodePaired = _nodeService?.IsPaired ?? false, - nodePendingApproval = _nodeService?.IsPendingApproval ?? false, - gatewayVersion = _appState!.GatewaySelf?.ServerVersion, - sessionCount = _appState!.Sessions?.Length ?? 0, - nodeCount = _appState!.Nodes?.Length ?? 0, - operatorScopes = _connectionManager?.OperatorClient?.GrantedOperatorScopes.ToArray() ?? Array.Empty(), - operatorDeviceId = _connectionManager?.CurrentSnapshot.OperatorDeviceId, + var snapshot = _connectionManager?.CurrentSnapshot; + return new + { + connectionStatus = _appState!.Status.ToString(), + overallState = snapshot?.OverallState.ToString(), + operatorState = snapshot?.OperatorState.ToString(), + nodeState = snapshot?.NodeState.ToString(), + nodeConnected = snapshot?.NodeState == RoleConnectionState.Connected, + nodePaired = snapshot?.NodePairingStatus == PairingStatus.Paired, + nodePendingApproval = snapshot?.NodeState == RoleConnectionState.PairingRequired, + nodeError = snapshot?.NodeError, + gatewayVersion = _appState!.GatewaySelf?.ServerVersion, + sessionCount = _appState!.Sessions?.Length ?? 0, + nodeCount = _appState!.Nodes?.Length ?? 0, + operatorScopes = _connectionManager?.OperatorClient?.GrantedOperatorScopes.ToArray() ?? Array.Empty(), + operatorDeviceId = snapshot?.OperatorDeviceId, + }; }; app.SessionsHandler = async (agentId) => @@ -149,6 +157,15 @@ private void WireAppCapabilityHandlers() var converted = Convert.ChangeType(value, prop.PropertyType); prop.SetValue(_settings, converted); _settings.Save(); + OnSettingsSaved(this, EventArgs.Empty); + var runtimeError = McpRuntimeStatePolicy.GetSettingsSetError( + name, + converted, + _nodeService?.IsMcpRunning == true, + _nodeService?.McpStartupError); + if (!string.IsNullOrWhiteSpace(runtimeError)) + return new { error = runtimeError }; + return new { name, value = prop.GetValue(_settings) }; } catch (Exception ex) @@ -160,9 +177,17 @@ private void WireAppCapabilityHandlers() app.MenuHandler = () => { + var snapshot = _connectionManager?.CurrentSnapshot; var items = new List { - new { type = "status", status = _appState!.Status.ToString() }, + new + { + type = "status", + status = _appState!.Status.ToString(), + overallState = snapshot?.OverallState.ToString(), + nodeState = snapshot?.NodeState.ToString(), + nodeError = snapshot?.NodeError + }, new { type = "sessions", count = _appState!.Sessions?.Length ?? 0 }, new { type = "nodes", count = _appState!.Nodes?.Length ?? 0 }, }; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 2096f12fb..af833fb82 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -172,10 +172,10 @@ public IntPtr GetHubWindowHandle() /// /// Cached connection status — sole writer is OnManagerStateChanged. /// Reads are safe from any thread; derives from the connection manager's state machine. - /// SSH tunnel errors in EnsureSshTunnelConfigured also write this temporarily (Phase 3 moves tunnel to manager). /// private WeakReference? _connectionToggleRef; private bool _suspendConnectionToggleEvent; + private string? _lastManagerConnectedSideEffectsKey; // FrozenDictionary for O(1) case-insensitive notification type → setting lookup — no per-call allocation. private static readonly System.Collections.Frozen.FrozenDictionary> s_notifTypeMap = @@ -212,6 +212,8 @@ public IntPtr GetHubWindowHandle() private const string ConnectionIssueNotificationId = "connection:issue"; private const string ConnectionIssueNotificationDedupeKey = "connection:issue"; + private const string McpStartupNotificationId = "mcp:startup"; + private const string McpStartupNotificationDedupeKey = "mcp:startup"; private const string SandboxRiskNotificationId = "sandbox:risk"; private const string SandboxRiskNotificationDedupeKey = "sandbox:risk"; private static readonly TimeSpan SandboxRiskProbeRefreshInterval = TimeSpan.FromMinutes(5); @@ -1351,6 +1353,7 @@ private TrayMenuSnapshot CaptureTrayMenuSnapshot() return new TrayMenuSnapshot { CurrentStatus = _appState!.Status, + OverallState = _connectionManager?.CurrentSnapshot.OverallState, AuthFailureMessage = _appState?.AuthFailureMessage, GatewayUrl = _gatewayRegistry?.GetActive()?.Url ?? _settings?.GetEffectiveGatewayUrl(), GatewaySelf = _appState?.GatewaySelf, @@ -1370,6 +1373,8 @@ private TrayMenuSnapshot CaptureTrayMenuSnapshot() SetupMenuLabel = setupMenuLabel, ShowSetupMenuEntry = !hasSetupManagedLocalWslGateway, LastUpdated = _appState?.LastCheckTime, + IsMcpRunning = _nodeService?.IsMcpRunning == true, + McpStartupError = _nodeService?.McpStartupError, }; } @@ -1793,13 +1798,31 @@ private bool TryStartLocalMcpOnlyNode() try { nodeService.StartLocalOnlyAsync().GetAwaiter().GetResult(); + var notificationPlan = McpRuntimeStatePolicy.PlanStartupNotification( + _settings.EnableMcpServer, + nodeService.IsMcpRunning, + nodeService.McpStartupError); + if (notificationPlan.ShouldShow) + { + Logger.Error($"Failed to start MCP-only node service: {notificationPlan.Message}"); + ApplyMcpStartupNotificationPlan(notificationPlan); + return false; + } + WireAppCapabilityHandlers(); + ApplyMcpStartupNotificationPlan(notificationPlan); Logger.Info("Started MCP-only node service without gateway connection"); return true; } catch (Exception ex) { Logger.Error($"Failed to start MCP-only node service: {ex}"); + nodeService.SetMcpStartupError($"MCP server startup failed: {ex.Message}"); + ApplyMcpStartupNotificationPlan( + McpRuntimeStatePolicy.PlanStartupNotification( + _settings.EnableMcpServer, + nodeService.IsMcpRunning, + nodeService.McpStartupError)); return false; } } @@ -1854,30 +1877,20 @@ private void RaiseChatProviderChanged() /// /// Handles the connection manager's StateChanged event. /// Maps the snapshot to the existing tray icon / UI status system. - /// Authoritative writer of gateway lifecycle status. Local prerequisite - /// failures can still mark the app Error before the manager can connect. + /// Authoritative writer of gateway lifecycle status. /// private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot snap) { - // Map OverallConnectionState to the existing ConnectionStatus enum - // for backward compat with tray icon and hub window - var mapped = snap.OverallState switch - { - OverallConnectionState.Idle => ConnectionStatus.Disconnected, - OverallConnectionState.Connecting => ConnectionStatus.Connecting, - OverallConnectionState.Connected => ConnectionStatus.Connected, - OverallConnectionState.Ready => ConnectionStatus.Connected, - OverallConnectionState.Degraded => ConnectionStatus.Connected, - OverallConnectionState.PairingRequired => ConnectionStatus.Connecting, - OverallConnectionState.Error => ConnectionStatus.Error, - OverallConnectionState.Disconnecting => ConnectionStatus.Disconnected, - _ => ConnectionStatus.Disconnected - }; + var mapped = ConnectionStatusPresenter.ToLegacyStatus(snap); + var connectedSideEffectsKey = snap.OperatorState == RoleConnectionState.Connected + ? $"{snap.GatewayId ?? snap.GatewayUrl ?? "unknown"}|{snap.OperatorDeviceId ?? "unknown"}" + : null; OnUiThread(() => { if (_appState != null) _appState.Status = mapped; + _hubWindow?.UpdateTitleBarStatus(snap, mapped); UpdateTrayIcon(); - SyncConnectionToggle(mapped); + SyncConnectionToggle(mapped, snap.OverallState); UpdateConnectionIssueNotification(snap); if (mapped is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error) { @@ -1885,6 +1898,20 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna _trayMenuWindow?.HideCascade(); } }); + + if (connectedSideEffectsKey != null) + { + if (!string.Equals(_lastManagerConnectedSideEffectsKey, connectedSideEffectsKey, StringComparison.Ordinal)) + { + _lastManagerConnectedSideEffectsKey = connectedSideEffectsKey; + _ = RunHealthCheckAsync(); + _ = TryConnectLocalNodeServiceAsync(); + } + } + else + { + _lastManagerConnectedSideEffectsKey = null; + } } private NodeService? EnsureNodeService(SettingsManager settings) @@ -2222,6 +2249,35 @@ private void UpdateConnectionIssueNotification(GatewayConnectionSnapshot snapsho id: ConnectionIssueNotificationId); } + private void ShowMcpStartupFailureNotification(string message) + { + AppNotificationPublisher.Show( + _appNotificationService, + "Local MCP failed", + message, + "connection", + "mcp", + AppNotificationSeverity.Error, + McpStartupNotificationDedupeKey, + "connection", + LocalizationHelper.GetString("AppNotification_ActionOpenConnection"), + id: McpStartupNotificationId); + } + + private void ApplyMcpStartupNotificationPlan(McpStartupNotificationPlan plan) + { + if (plan.ShouldShow && !string.IsNullOrWhiteSpace(plan.Message)) + { + ShowMcpStartupFailureNotification(plan.Message); + } + else if (plan.ShouldDismiss) + { + _appNotificationService?.Dismiss(McpStartupNotificationId); + } + + UpdateTrayIcon(); + } + private static bool TryBuildConnectionIssueNotification( GatewayConnectionSnapshot snapshot, out string title, @@ -2236,6 +2292,32 @@ private static bool TryBuildConnectionIssueNotification( category = "lifecycle"; key = ""; + if (snapshot.OperatorPairingRequired) + { + title = LocalizationHelper.GetString("AppNotification_GatewayPairingRequired_Title"); + message = string.IsNullOrWhiteSpace(snapshot.OperatorDeviceId) + ? LocalizationHelper.GetString("AppNotification_GatewayPairingRequired_GenericMessage") + : LocalizationHelper.Format( + "AppNotification_GatewayPairingRequired_DeviceMessageFormat", + DeviceIdForLog(snapshot.OperatorDeviceId)); + category = "pairing"; + key = $"operator-pairing:{snapshot.OperatorDeviceId ?? "unknown"}"; + return true; + } + + if (snapshot.OverallState == OverallConnectionState.PairingRequired && + snapshot.NodeState == RoleConnectionState.PairingRequired) + { + title = LocalizationHelper.GetString("AppNotification_GatewayPairingRequired_Title"); + message = "Approve the Windows node pairing request on the gateway host."; + category = "pairing"; + key = $"node-pairing:{snapshot.NodeDeviceId ?? snapshot.NodePairingRequestId ?? "unknown"}"; + return true; + } + + if (TryBuildNodeConnectionIssueNotification(snapshot, out title, out message, out severity, out category, out key)) + return true; + if (snapshot.OverallState == OverallConnectionState.Error) { title = LocalizationHelper.GetString("AppNotification_GatewayConnectionFailed_Title"); @@ -2248,39 +2330,42 @@ private static bool TryBuildConnectionIssueNotification( return true; } - if (snapshot.OperatorPairingRequired) + return false; + } + + private static bool TryBuildNodeConnectionIssueNotification( + GatewayConnectionSnapshot snapshot, + out string title, + out string message, + out AppNotificationSeverity severity, + out string category, + out string key) + { + title = ""; + message = ""; + severity = AppNotificationSeverity.Warning; + category = "node"; + key = ""; + + if (snapshot.OperatorState == RoleConnectionState.Error) + return false; + + if (snapshot.NodeState == RoleConnectionState.RateLimited) { - title = LocalizationHelper.GetString("AppNotification_GatewayPairingRequired_Title"); - message = string.IsNullOrWhiteSpace(snapshot.OperatorDeviceId) - ? LocalizationHelper.GetString("AppNotification_GatewayPairingRequired_GenericMessage") - : LocalizationHelper.Format( - "AppNotification_GatewayPairingRequired_DeviceMessageFormat", - DeviceIdForLog(snapshot.OperatorDeviceId)); - category = "pairing"; - key = $"operator-pairing:{snapshot.OperatorDeviceId ?? "unknown"}"; + title = LocalizationHelper.GetString("AppNotification_WindowsNodeRateLimited_Title"); + message = snapshot.NodeError ?? LocalizationHelper.GetString("AppNotification_WindowsNodeRateLimited_DefaultMessage"); + key = $"node-rate-limited:{message}"; return true; } - if (snapshot.OverallState == OverallConnectionState.Degraded) + if (snapshot.NodeState is RoleConnectionState.Error or RoleConnectionState.PairingRejected || + !string.IsNullOrWhiteSpace(snapshot.NodeError)) { - if (snapshot.NodeState == RoleConnectionState.Error) - { - title = LocalizationHelper.GetString("AppNotification_WindowsNodeConnectionFailed_Title"); - message = snapshot.NodeError ?? LocalizationHelper.GetString("AppNotification_WindowsNodeConnectionFailed_DefaultMessage"); - severity = AppNotificationSeverity.Error; - category = "node"; - key = $"node-error:{message}"; - return true; - } - - if (snapshot.NodeState == RoleConnectionState.RateLimited) - { - title = LocalizationHelper.GetString("AppNotification_WindowsNodeRateLimited_Title"); - message = snapshot.NodeError ?? LocalizationHelper.GetString("AppNotification_WindowsNodeRateLimited_DefaultMessage"); - category = "node"; - key = $"node-rate-limited:{message}"; - return true; - } + title = LocalizationHelper.GetString("AppNotification_WindowsNodeConnectionFailed_Title"); + message = snapshot.NodeError ?? LocalizationHelper.GetString("AppNotification_WindowsNodeConnectionFailed_DefaultMessage"); + severity = AppNotificationSeverity.Error; + key = $"node-error:{message}"; + return true; } return false; @@ -2546,25 +2631,10 @@ private void OnGatewayConnectionStatusChanged(object? sender, ConnectionStatus s _appState.AuthFailureMessage = null; } - UpdateTrayIcon(); OnUiThread(() => { UpdateStatusDetailWindow(); - SyncConnectionToggle(status); - if (status is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error) - { - // Dismiss the tray menu on state change — it will capture fresh data on next open - _trayMenuWindow?.HideCascade(); - } }); - - if (status == ConnectionStatus.Connected) - { - _ = RunHealthCheckAsync(); - // Gateway-node mode connects the NodeService after operator auth; MCP-only - // mode keeps serving local tools and must not escalate into node pairing. - _ = TryConnectLocalNodeServiceAsync(); - } } /// @@ -2977,7 +3047,7 @@ private void ClearSandboxRiskNotification() } - private void SyncConnectionToggle(ConnectionStatus status) + private void SyncConnectionToggle(ConnectionStatus status, OverallConnectionState? overallState = null) { if (_connectionToggleRef == null) return; @@ -2991,16 +3061,22 @@ private void SyncConnectionToggle(ConnectionStatus status) return; } - var shouldBeOn = status == ConnectionStatus.Connected; - var canToggle = status is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error; + var shouldBeOn = ConnectionStatusPresenter.IsLiveOrPending(overallState, status); + var canToggle = overallState switch + { + OverallConnectionState.Connecting or OverallConnectionState.Disconnecting => false, + null => status is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error, + _ => true + }; + var statusText = ConnectionStatusPresenter.PlainText(overallState, status); _suspendConnectionToggleEvent = true; try { TrayMenuWindow.SetMenuToggleSwitchState(toggle, shouldBeOn, canToggle); ToolTipService.SetToolTip(toggle, - shouldBeOn ? "Connected - toggle off to disconnect" + shouldBeOn ? $"{statusText} - toggle off to disconnect" : status == ConnectionStatus.Connecting ? "Connecting..." - : "Disconnected - toggle on to connect"); + : $"{statusText} - toggle on to connect"); } finally { @@ -3103,17 +3179,23 @@ private async Task RunHealthCheckAsync(bool userInitiated = false) private string BuildTrayTooltip() => new TrayTooltipBuilder(CaptureTraySnapshot()).Build(); - private TrayStateSnapshot CaptureTraySnapshot() => new TrayStateSnapshot + private TrayStateSnapshot CaptureTraySnapshot() { - Status = _appState!.Status, - CurrentActivity = _appState!.CurrentActivity, - Channels = _appState!.Channels, - Nodes = _appState!.Nodes, - LocalNodeFallback = _nodeService?.GetLocalNodeInfo(), - AuthFailureMessage = _appState!.AuthFailureMessage, - LastCheckTime = _appState!.LastCheckTime, - Settings = _settings - }; + return new TrayStateSnapshot + { + Status = _appState!.Status, + OverallState = _connectionManager?.CurrentSnapshot.OverallState, + CurrentActivity = _appState!.CurrentActivity, + Channels = _appState!.Channels, + Nodes = _appState!.Nodes, + LocalNodeFallback = _nodeService?.GetLocalNodeInfo(), + AuthFailureMessage = _appState!.AuthFailureMessage, + LastCheckTime = _appState!.LastCheckTime, + Settings = _settings, + IsMcpRunning = _nodeService?.IsMcpRunning == true, + McpStartupError = _nodeService?.McpStartupError + }; + } #endregion @@ -3277,6 +3359,14 @@ private void OnSettingsSaved(object? sender, EventArgs e) { var nodeService = EnsureNodeService(_settings); nodeService?.SetMcpEnabled(_settings.EnableMcpServer); + if (nodeService != null) + { + ApplyMcpStartupNotificationPlan( + McpRuntimeStatePolicy.PlanStartupNotification( + _settings.EnableMcpServer, + nodeService.IsMcpRunning, + nodeService.McpStartupError)); + } WireAppCapabilityHandlers(); } @@ -3564,6 +3654,7 @@ private AppStateSnapshot CaptureSnapshot() return new AppStateSnapshot { Status = _appState!.Status, + OverallState = _connectionManager?.CurrentSnapshot.OverallState, LastCheckTime = _appState!.LastCheckTime, Channels = _appState!.Channels, Sessions = _appState!.Sessions, @@ -3576,6 +3667,8 @@ private AppStateSnapshot CaptureSnapshot() LastUpdateInfo = _appState!.UpdateInfo, Settings = _settings, NodeService = _nodeService, + IsMcpRunning = _nodeService?.IsMcpRunning == true, + McpStartupError = _nodeService?.McpStartupError, NodePairingApprovalKind = _connectionManager?.CurrentSnapshot.NodePairingApprovalKind ?? PairingApprovalKind.Unknown, NodePairingRequestId = _connectionManager?.CurrentSnapshot.NodePairingRequestId, @@ -3829,7 +3922,13 @@ private bool TryResolveChatCredentials( private void OpenDashboard(string? path = null) { if (_settings == null) return; - if (!EnsureSshTunnelConfigured()) return; + if (!EnsureSshTunnelConfigured()) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText(_sshTunnelService?.LastError ?? "Check SSH tunnel settings and logs.")); + return; + } if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) { @@ -4429,7 +4528,6 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 || _settings.SshTunnelLocalPort is < 1 or > 65535) { Logger.Warn("SSH tunnel is enabled but settings are incomplete"); - _appState!.Status = ConnectionStatus.Error; UpdateTrayIcon(); return false; } @@ -4459,7 +4557,6 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 || catch (Exception ex) { Logger.Error($"Failed to start SSH tunnel: {ex.Message}"); - _appState!.Status = ConnectionStatus.Error; UpdateTrayIcon(); return false; } diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs index e08310a59..905a77537 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs @@ -359,6 +359,7 @@ private static ConnectionPagePlan BuildCockpitDegraded( RoleConnectionState.PairingRejected => "Node pairing was rejected.", RoleConnectionState.RateLimited => "Node is rate-limited by the gateway.", RoleConnectionState.Error => "Node reported an error.", + RoleConnectionState.Idle when snap.NodeConnectionIntended => "Node mode is enabled, but the node has not connected.", _ => "Connection is impaired.", }; @@ -710,6 +711,7 @@ private static NodeCardState BuildNodeCardState(GatewayConnectionSnapshot snap, RoleConnectionState.PairingRejected => NodeCardState.OnNodeRejected, RoleConnectionState.RateLimited => NodeCardState.OnNodeRateLimited, RoleConnectionState.Error => NodeCardState.OnNodeError, + RoleConnectionState.Idle when snap.NodeConnectionIntended => NodeCardState.OnNodeError, _ when CountEnabledCapabilities(settings) == 0 => NodeCardState.OnPermissionsIncomplete, _ => NodeCardState.OnHealthy, }; diff --git a/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs index 979a2b1a9..58c9efec5 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs @@ -9,6 +9,7 @@ namespace OpenClawTray.Services; internal sealed record AppStateSnapshot { public ConnectionStatus Status { get; init; } + public OverallConnectionState? OverallState { get; init; } public DateTime LastCheckTime { get; init; } public ChannelHealth[] Channels { get; init; } = []; public SessionInfo[] Sessions { get; init; } = []; @@ -21,6 +22,8 @@ internal sealed record AppStateSnapshot public UpdateCommandCenterInfo LastUpdateInfo { get; init; } = new(); public SettingsManager? Settings { get; init; } public NodeService? NodeService { get; init; } + public bool IsMcpRunning { get; init; } + public string? McpStartupError { get; init; } public PairingApprovalKind NodePairingApprovalKind { get; init; } public string? NodePairingRequestId { get; init; } public SshTunnelSnapshot? SshTunnelSnapshot { get; init; } diff --git a/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs index 47ceb495e..4ad1aeedd 100644 --- a/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs @@ -81,6 +81,34 @@ node.ApprovalState is GatewayNodeApprovalState.PendingApproval or }); } + var overallState = _snapshot.OverallState; + var mcpStartupError = _snapshot.McpStartupError; + + if (_snapshot.Settings?.EnableMcpServer == true && + !string.IsNullOrWhiteSpace(mcpStartupError)) + { + warnings.Insert(0, new GatewayDiagnosticWarning + { + Severity = GatewayDiagnosticSeverity.Critical, + Category = "mcp", + Title = "Local MCP failed", + Detail = mcpStartupError + }); + } + + if (_snapshot.Settings?.EnableMcpServer == true && + (_snapshot.Settings?.EnableNodeMode ?? false) == false && + _snapshot.IsMcpRunning) + { + warnings.Add(new GatewayDiagnosticWarning + { + Severity = GatewayDiagnosticSeverity.Info, + Category = "mcp", + Title = "Local MCP only", + Detail = "Local MCP tools are listening on this PC without a gateway node connection." + }); + } + if (shouldShowPendingLocalNodeApproval && _snapshot.NodeService?.IsPendingApproval == true && !string.IsNullOrWhiteSpace(_snapshot.NodeService.FullDeviceId)) @@ -103,7 +131,27 @@ node.ApprovalState is GatewayNodeApprovalState.PendingApproval or }); } - if (_snapshot.Status == ConnectionStatus.Error) + if (overallState == OpenClaw.Connection.OverallConnectionState.Degraded) + { + warnings.Insert(0, new GatewayDiagnosticWarning + { + Severity = GatewayDiagnosticSeverity.Warning, + Category = "gateway", + Title = "Connection degraded", + Detail = "The operator connection is available, but one required role is blocked." + }); + } + else if (overallState == OpenClaw.Connection.OverallConnectionState.PairingRequired) + { + warnings.Insert(0, new GatewayDiagnosticWarning + { + Severity = GatewayDiagnosticSeverity.Warning, + Category = "pairing", + Title = "Pairing required", + Detail = "Approve the pending operator or node pairing request to finish connecting." + }); + } + else if (_snapshot.Status == ConnectionStatus.Error) { warnings.Insert(0, new GatewayDiagnosticWarning { diff --git a/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs b/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs index 2bea86d1d..bd4da850d 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs @@ -1,4 +1,5 @@ using OpenClaw.Connection; +using OpenClaw.Shared; namespace OpenClawTray.Services; @@ -12,6 +13,57 @@ internal enum ConnectionStatusAccent internal static class ConnectionStatusPresenter { + public static ConnectionStatus ToLegacyStatus(OverallConnectionState overall) => overall switch + { + OverallConnectionState.Connected or OverallConnectionState.Ready or OverallConnectionState.Degraded => ConnectionStatus.Connected, + OverallConnectionState.Connecting => ConnectionStatus.Connecting, + OverallConnectionState.Idle or OverallConnectionState.Disconnecting => ConnectionStatus.Disconnected, + OverallConnectionState.PairingRequired or + OverallConnectionState.Error => ConnectionStatus.Error, + _ => ConnectionStatus.Disconnected, + }; + + public static ConnectionStatus ToLegacyStatus(GatewayConnectionSnapshot snapshot) + { + if (snapshot.OperatorState == RoleConnectionState.Connected) + return ConnectionStatus.Connected; + + return ToLegacyStatus(snapshot.OverallState); + } + + public static bool IsHealthy(OverallConnectionState? overall, ConnectionStatus fallback) => + overall is OverallConnectionState.Connected or OverallConnectionState.Ready || + (overall is null && fallback == ConnectionStatus.Connected); + + public static bool IsLiveOrPending(OverallConnectionState? overall, ConnectionStatus fallback) => + overall is OverallConnectionState.Connected + or OverallConnectionState.Ready + or OverallConnectionState.Degraded + or OverallConnectionState.PairingRequired + or OverallConnectionState.Connecting || + (overall is null && fallback is ConnectionStatus.Connected or ConnectionStatus.Connecting); + + public static bool IsOperatorChannelLive(GatewayConnectionSnapshot snapshot) => + snapshot.OperatorState == RoleConnectionState.Connected; + + public static string PlainText(OverallConnectionState? overall, ConnectionStatus fallback) => overall switch + { + OverallConnectionState.Connected or OverallConnectionState.Ready => "Connected", + OverallConnectionState.Connecting => "Connecting", + OverallConnectionState.Degraded => "Degraded", + OverallConnectionState.PairingRequired => "Pairing required", + OverallConnectionState.Error => "Connection error", + OverallConnectionState.Disconnecting => "Disconnecting", + OverallConnectionState.Idle => "Disconnected", + _ => fallback switch + { + ConnectionStatus.Connected => "Connected", + ConnectionStatus.Connecting => "Connecting", + ConnectionStatus.Error => "Connection error", + _ => "Disconnected", + }, + }; + public static (string LabelKey, ConnectionStatusAccent Accent) Pill(OverallConnectionState overall) => overall switch { OverallConnectionState.Connected or OverallConnectionState.Ready => diff --git a/src/OpenClaw.Tray.WinUI/Services/McpRuntimeStatePolicy.cs b/src/OpenClaw.Tray.WinUI/Services/McpRuntimeStatePolicy.cs new file mode 100644 index 000000000..bbc494930 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/McpRuntimeStatePolicy.cs @@ -0,0 +1,46 @@ +using System; + +namespace OpenClawTray.Services; + +internal readonly record struct McpStartupNotificationPlan(bool ShouldShow, string? Message) +{ + public bool ShouldDismiss => !ShouldShow; +} + +internal static class McpRuntimeStatePolicy +{ + public const string DefaultStartupError = "Local MCP server did not start."; + + public static McpStartupNotificationPlan PlanStartupNotification( + bool enableMcpServer, + bool isMcpRunning, + string? startupError) + { + if (!enableMcpServer) + return new McpStartupNotificationPlan(false, null); + + if (!string.IsNullOrWhiteSpace(startupError)) + return new McpStartupNotificationPlan(true, startupError); + + return isMcpRunning + ? new McpStartupNotificationPlan(false, null) + : new McpStartupNotificationPlan(true, DefaultStartupError); + } + + public static string? GetSettingsSetError( + string settingName, + object? convertedValue, + bool isMcpRunning, + string? startupError) + { + if (!string.Equals(settingName, nameof(SettingsManager.EnableMcpServer), StringComparison.OrdinalIgnoreCase) || + convertedValue is not bool enableMcpServer || + !enableMcpServer) + { + return null; + } + + var plan = PlanStartupNotification(enableMcpServer, isMcpRunning, startupError); + return plan.ShouldShow ? plan.Message : null; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index b344183af..ea939a464 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -161,6 +161,7 @@ public sealed class NodeService : IDisposable, IAsyncDisposable public string McpEndpoint => McpServerUrl; /// Last MCP server startup error, or null if it started cleanly. Surfaced by Settings UI. public string? McpStartupError => _mcpStartupError; + public void SetMcpStartupError(string? error) => _mcpStartupError = string.IsNullOrWhiteSpace(error) ? null : error; // Events public event EventHandler? StatusChanged; @@ -243,8 +244,16 @@ public Task StartLocalOnlyAsync() // and are consumed by the MCP bridge directly. _logger.Info("Starting Windows Node in MCP-only mode (no gateway)"); _token = null; + _mcpStartupError = null; - RegisterCapabilities(); + try + { + RegisterCapabilities(); + } + catch (Exception ex) + { + SetMcpStartupFailure(ex, "capability registration"); + } return Task.CompletedTask; } @@ -760,10 +769,10 @@ private void InvalidateMxcAvailability() } } - private void StartMcpServer() + private bool StartMcpServer() { - if (!_enableMcpServer) return; - if (_mcpServer != null) return; + if (!_enableMcpServer) return true; + if (_mcpServer != null) return true; McpHttpServer? attempt = null; try { @@ -807,6 +816,7 @@ private void StartMcpServer() attempt.Start(); _mcpServer = attempt; _mcpStartupError = null; + return true; } catch (Exception ex) { @@ -822,6 +832,7 @@ private void StartMcpServer() _logger.Debug($"[MCP] Cleanup of half-started listener failed: {cleanupEx.Message}"); } _mcpServer = null; + return false; } } @@ -838,9 +849,16 @@ private void StartMcpServer() 32 or 183 => $"Port {port} is already in use. Stop the other process or change the MCP port.", _ => $"HTTP listener error {hle.ErrorCode}: {hle.Message}", }, - _ => ex.Message, + InvalidOperationException => $"Configuration error: {ex.Message}", + _ => $"MCP server startup failed: {ex.Message}", }; + private void SetMcpStartupFailure(Exception ex, string phase) + { + _mcpStartupError = DescribeMcpStartupFailure(ex, McpPort); + _logger.Error($"[MCP] Failed during {phase}: {_mcpStartupError}", ex); + } + private void StopMcpServer() { ObserveBackgroundFault(StopMcpServerAsync(), "[MCP] Dispose error"); @@ -889,16 +907,30 @@ public void SetMcpEnabled(bool enabled) if (_mcpServer != null) return; // already running _logger.Info("[MCP] SetMcpEnabled(true) — starting MCP server"); + _mcpStartupError = null; bool needsCapabilities; lock (_capabilitiesLock) { needsCapabilities = _capabilities.Count == 0; } - if (needsCapabilities) + try + { + if (needsCapabilities) + { + RegisterCapabilities(); + } + else + { + StartMcpServer(); + } + } + catch (Exception ex) { - RegisterCapabilities(); + SetMcpStartupFailure(ex, "MCP enable"); } - else + + if (_mcpServer == null && string.IsNullOrWhiteSpace(_mcpStartupError)) { - StartMcpServer(); + _mcpStartupError = "MCP server startup failed: listener did not start."; + _logger.Error($"[MCP] {_mcpStartupError}"); } } else diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs index 7a5a254d9..760d63dbd 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs @@ -51,7 +51,8 @@ internal TrayDashboardSummaryBuilder(TrayMenuSnapshot snapshot, DateTime? nowUtc internal TrayDashboardSummary Build() { - var isConnected = _snapshot.CurrentStatus == ConnectionStatus.Connected; + var overallState = _snapshot.OverallState; + var isConnected = ConnectionStatusPresenter.IsHealthy(overallState, _snapshot.CurrentStatus); var (severity, headline) = ClassifyHealth(); @@ -78,11 +79,33 @@ internal TrayDashboardSummary Build() if (!string.IsNullOrEmpty(_snapshot.AuthFailureMessage)) return (TrayHealthSeverity.Critical, "Authentication failed"); + if (HasRelevantMcpStartupError()) + return (TrayHealthSeverity.Critical, "Local MCP failed"); + + if (IsStandaloneMcpOnly()) + { + return (TrayHealthSeverity.Ok, "Local MCP only"); + } + var pending = (_snapshot.NodePairList?.Pending.Count ?? 0) + (_snapshot.DevicePairList?.Pending.Count ?? 0); if (pending > 0) return (TrayHealthSeverity.Caution, $"Pairing approval pending ({pending})"); + if (_snapshot.OverallState is { } overall) + { + return overall switch + { + OpenClaw.Connection.OverallConnectionState.Connected or + OpenClaw.Connection.OverallConnectionState.Ready => (TrayHealthSeverity.Ok, "Connected"), + OpenClaw.Connection.OverallConnectionState.Connecting => (TrayHealthSeverity.Caution, "Connecting…"), + OpenClaw.Connection.OverallConnectionState.Degraded => (TrayHealthSeverity.Caution, "Connection degraded"), + OpenClaw.Connection.OverallConnectionState.PairingRequired => (TrayHealthSeverity.Caution, "Pairing required"), + OpenClaw.Connection.OverallConnectionState.Error => (TrayHealthSeverity.Critical, "Connection error"), + _ => (TrayHealthSeverity.Neutral, "Disconnected"), + }; + } + return _snapshot.CurrentStatus switch { ConnectionStatus.Connected => (TrayHealthSeverity.Ok, "Connected"), @@ -92,6 +115,17 @@ internal TrayDashboardSummary Build() }; } + private bool IsStandaloneMcpOnly() => + _snapshot.Settings?.EnableMcpServer == true && + (_snapshot.Settings?.EnableNodeMode ?? false) == false && + _snapshot.IsMcpRunning && + (_snapshot.OverallState is null or OpenClaw.Connection.OverallConnectionState.Idle) && + _snapshot.CurrentStatus != ConnectionStatus.Connected; + + private bool HasRelevantMcpStartupError() => + _snapshot.Settings?.EnableMcpServer == true && + !string.IsNullOrWhiteSpace(_snapshot.McpStartupError); + private string? BuildHeartbeat() { if (_snapshot.LastUpdated is not { } updated) diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs index 5a818bc4c..808db3dfa 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs @@ -1,3 +1,4 @@ +using OpenClaw.Connection; using OpenClaw.Shared; using OpenClawTray.Services; using System; @@ -8,6 +9,7 @@ internal sealed record TrayMenuSnapshot { // ── Conexión ── internal required ConnectionStatus CurrentStatus { get; init; } + internal OverallConnectionState? OverallState { get; init; } internal required string? AuthFailureMessage { get; init; } internal required string? GatewayUrl { get; init; } internal required GatewaySelfInfo? GatewaySelf { get; init; } @@ -37,4 +39,6 @@ internal sealed record TrayMenuSnapshot // ── Dashboard glance ── internal DateTime? LastUpdated { get; init; } + internal bool IsMcpRunning { get; init; } + internal string? McpStartupError { get; init; } } diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs index aa63cf6b5..705503761 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs @@ -44,8 +44,10 @@ internal void Build(TrayMenuWindow menu) // ToggleAction delegates; recreate the lookup each rebuild. _permToggleActions.Clear(); - var isConnected = _snapshot.CurrentStatus == ConnectionStatus.Connected; - var statusText = LocalizationHelper.GetConnectionStatusText(_snapshot.CurrentStatus); + var overallState = _snapshot.OverallState; + var isConnected = ConnectionStatusPresenter.IsHealthy(overallState, _snapshot.CurrentStatus); + var isLiveOrPending = ConnectionStatusPresenter.IsLiveOrPending(overallState, _snapshot.CurrentStatus); + var statusText = ConnectionStatusPresenter.PlainText(overallState, _snapshot.CurrentStatus); // Cache theme brushes once per build so cells don't each do a // resource lookup. The previous implementation looked up @@ -97,13 +99,19 @@ internal void Build(TrayMenuWindow menu) Grid.SetColumn(brandRow, 0); brandGrid.Children.Add(brandRow); - var canToggleConnection = _snapshot.CurrentStatus == ConnectionStatus.Connected - || _snapshot.CurrentStatus == ConnectionStatus.Disconnected - || _snapshot.CurrentStatus == ConnectionStatus.Error; - var connectionToggle = menu.CreateMenuToggleSwitch(isConnected, "Gateway connection", canToggleConnection); + var canToggleConnection = overallState switch + { + null => _snapshot.CurrentStatus == ConnectionStatus.Connected + || _snapshot.CurrentStatus == ConnectionStatus.Disconnected + || _snapshot.CurrentStatus == ConnectionStatus.Error, + OpenClaw.Connection.OverallConnectionState.Connecting or + OpenClaw.Connection.OverallConnectionState.Disconnecting => false, + _ => true + }; + var connectionToggle = menu.CreateMenuToggleSwitch(isLiveOrPending, "Gateway connection", canToggleConnection); connectionToggle.Margin = new Thickness(0); ToolTipService.SetToolTip(connectionToggle, - isConnected ? "Connected - toggle off to disconnect" : "Disconnected - toggle on to connect"); + isLiveOrPending ? $"{statusText} - toggle off to disconnect" : $"{statusText} - toggle on to connect"); connectionToggle.Toggled += (s, ev) => { if (_callbacks.IsConnectionToggleSuspended()) @@ -162,7 +170,12 @@ internal void Build(TrayMenuWindow menu) Width = 8, Height = 8, VerticalAlignment = VerticalAlignment.Center, Fill = isConnected ? successBrush - : _snapshot.CurrentStatus == ConnectionStatus.Connecting ? cautionBrush + : (overallState is OpenClaw.Connection.OverallConnectionState.Error || + (overallState is null && _snapshot.CurrentStatus == ConnectionStatus.Error)) ? criticalBrush + : ((overallState is OpenClaw.Connection.OverallConnectionState.Connecting + or OpenClaw.Connection.OverallConnectionState.Degraded + or OpenClaw.Connection.OverallConnectionState.PairingRequired) || + (overallState is null && _snapshot.CurrentStatus == ConnectionStatus.Connecting)) ? cautionBrush : neutralBrush }); gwNameRow.Children.Add(new TextBlock diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs index 458c39cb8..b6b71f005 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs @@ -1,3 +1,4 @@ +using OpenClaw.Connection; using OpenClaw.Shared; using System; @@ -6,6 +7,7 @@ namespace OpenClawTray.Services; internal sealed record TrayStateSnapshot { public ConnectionStatus Status { get; init; } + public OverallConnectionState? OverallState { get; init; } public AgentActivity? CurrentActivity { get; init; } public ChannelHealth[] Channels { get; init; } = []; public GatewayNodeInfo[] Nodes { get; init; } = []; @@ -13,4 +15,6 @@ internal sealed record TrayStateSnapshot public string? AuthFailureMessage { get; init; } public DateTime LastCheckTime { get; init; } public SettingsManager? Settings { get; init; } + public bool IsMcpRunning { get; init; } + public string? McpStartupError { get; init; } } diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs index 5eec68029..6b82c73e0 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs @@ -31,12 +31,16 @@ internal string Build() nodeOnline = localNode.IsOnline ? 1 : 0; } + var statusText = BuildStatusText(); + var overallState = _snapshot.OverallState; + var isHealthy = ConnectionStatusPresenter.IsHealthy(overallState, _snapshot.Status); var warningCount = 0; - if (_snapshot.Status != ConnectionStatus.Connected) warningCount++; + if (!isHealthy) warningCount++; if (_snapshot.AuthFailureMessage != null) warningCount++; - if (_snapshot.Channels.Length == 0 && _snapshot.Status == ConnectionStatus.Connected) warningCount++; + if (HasRelevantMcpStartupError()) warningCount++; + if (_snapshot.Channels.Length == 0 && isHealthy) warningCount++; - var tooltip = $"OpenClaw Tray - {_snapshot.Status}; " + + var tooltip = $"OpenClaw Tray - {statusText}; " + $"{topology.DisplayName}; " + $"Channels {channelReady}/{_snapshot.Channels.Length}; " + $"Nodes {nodeOnline}/{nodeTotal}; " + @@ -45,9 +49,33 @@ internal string Build() if (_snapshot.CurrentActivity != null && !string.IsNullOrEmpty(_snapshot.CurrentActivity.DisplayText)) { - tooltip = $"OpenClaw Tray - {_snapshot.CurrentActivity.DisplayText}; {_snapshot.Status}"; + tooltip = $"OpenClaw Tray - {_snapshot.CurrentActivity.DisplayText}; {statusText}"; } return TrayTooltipFormatter.FitShellTooltip(tooltip); } + + private string BuildStatusText() + { + if (HasRelevantMcpStartupError()) + return "Local MCP failed"; + + if (IsStandaloneMcpOnly()) + { + return "Local MCP only"; + } + + return ConnectionStatusPresenter.PlainText(_snapshot.OverallState, _snapshot.Status); + } + + private bool IsStandaloneMcpOnly() => + _snapshot.Settings?.EnableMcpServer == true && + _snapshot.Settings?.EnableNodeMode == false && + _snapshot.IsMcpRunning && + (_snapshot.OverallState is null or OpenClaw.Connection.OverallConnectionState.Idle) && + _snapshot.Status != ConnectionStatus.Connected; + + private bool HasRelevantMcpStartupError() => + _snapshot.Settings?.EnableMcpServer == true && + !string.IsNullOrWhiteSpace(_snapshot.McpStartupError); } diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs index b6495fcb8..c188731c2 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs @@ -772,6 +772,13 @@ private void UpdateTitleBarStatus(ConnectionStatus status) StatusPillDot.Fill = AccentBrush(accent); } + internal void UpdateTitleBarStatus(GatewayConnectionSnapshot snapshot, ConnectionStatus status) + { + var (text, accent) = ComputePillState(status, snapshot); + StatusPillText.Text = text; + StatusPillDot.Fill = AccentBrush(accent); + } + private static (string Text, ConnectionStatusAccent Accent) ComputePillState( ConnectionStatus status, GatewayConnectionSnapshot? snapshot) { diff --git a/src/OpenClaw.WinNode.Cli/skill.md b/src/OpenClaw.WinNode.Cli/skill.md index e72445241..bf61068cd 100644 --- a/src/OpenClaw.WinNode.Cli/skill.md +++ b/src/OpenClaw.WinNode.Cli/skill.md @@ -308,7 +308,7 @@ Returns `{ navigated, page }`. ### app.status Current connection / node state. -No params. Returns `{ connectionStatus, nodeConnected, nodePaired, nodePendingApproval, gatewayVersion, sessionCount, nodeCount }`. +No params. Returns `{ connectionStatus, overallState, operatorState, nodeState, nodeConnected, nodePaired, nodePendingApproval, nodeError, gatewayVersion, sessionCount, nodeCount }`. ### app.sessions Active sessions, optionally filtered by agent. @@ -340,15 +340,15 @@ Read a local app setting by name. Returns the setting value (type depends on the setting). ### app.settings.set -Set a local app setting. +Set a local app setting, persist it, and apply the same reconnect/reload behavior as saving settings in the app UI. ``` {"name": "string", "value": "string"} // both required ``` -Returns `{ name, value }`. +Returns `{ name, value }`; runtime apply failures surface as tool errors. ### app.menu Get tray menu state (status, session count, node count). No params. -Returns array of menu items. +Returns array of menu items; the status item includes `status`, `overallState`, `nodeState`, and `nodeError`. ### app.search Search the command palette and return matching commands. diff --git a/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs b/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs index 29b401b69..dcf71a49f 100644 --- a/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs +++ b/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs @@ -278,6 +278,22 @@ public void NodePairingRequired_WithOperatorConnected_DerivesPairingRequired() Assert.Equal(OpenClaw.Shared.PairingStatus.Pending, _sm.Current.NodePairingStatus); } + [Fact] + public void NodePairingRequired_FromNodeError_ClearsStaleNodeError() + { + _sm.SetNodeEnabled(true); + GoToConnected(); + _sm.StartNodeConnecting(); + Assert.True(_sm.TryTransition(ConnectionTrigger.NodeError, "transport failed")); + + Assert.True(_sm.TryTransition(ConnectionTrigger.NodePairingRequired)); + + Assert.Equal(OverallConnectionState.PairingRequired, _sm.Current.OverallState); + Assert.Equal(RoleConnectionState.PairingRequired, _sm.Current.NodeState); + Assert.Null(_sm.Current.NodeError); + Assert.Equal(OpenClaw.Shared.PairingStatus.Pending, _sm.Current.NodePairingStatus); + } + [Fact] public void SetNodeInfo_PendingWithoutRequestId_ClearsStaleRequestIdAndKind() { @@ -337,15 +353,17 @@ public void NodePairingRejected_DerivesDegraded() } [Fact] - public void NodeDisconnected_FromConnected_DerivesConnected() + public void NodeDisconnected_FromConnected_DerivesDegradedWhenNodeStillIntended() { _sm.SetNodeEnabled(true); GoToConnected(); _sm.StartNodeConnecting(); _sm.TryTransition(ConnectionTrigger.NodeConnected); Assert.True(_sm.TryTransition(ConnectionTrigger.NodeDisconnected)); - // Operator still connected, node idle → Connected (not Ready) + // Operator still connected, node mode still intended, node idle → Degraded (not healthy). Assert.Equal(RoleConnectionState.Idle, _sm.Current.NodeState); + Assert.Equal(OverallConnectionState.Degraded, _sm.Current.OverallState); + Assert.True(_sm.Current.NodeConnectionIntended); } [Fact] @@ -378,6 +396,7 @@ public void SetNodeEnabled_True_SetsNodeToIdle() { _sm.SetNodeEnabled(true); Assert.Equal(RoleConnectionState.Idle, _sm.Current.NodeState); + Assert.True(_sm.Current.NodeConnectionIntended); } [Fact] @@ -385,6 +404,32 @@ public void SetNodeEnabled_False_SetsNodeToDisabled() { _sm.SetNodeEnabled(false); Assert.Equal(RoleConnectionState.Disabled, _sm.Current.NodeState); + Assert.False(_sm.Current.NodeConnectionIntended); + } + + [Fact] + public void BlockNodeStart_WithOperatorConnected_DerivesDegradedAndKeepsReason() + { + _sm.SetNodeEnabled(true); + GoToConnected(); + + _sm.BlockNodeStart("No node credential available"); + + Assert.Equal(OverallConnectionState.Degraded, _sm.Current.OverallState); + Assert.Equal(RoleConnectionState.Error, _sm.Current.NodeState); + Assert.Equal("No node credential available", _sm.Current.NodeError); + Assert.True(_sm.Current.NodeConnectionIntended); + } + + [Fact] + public void BlockNodeStart_WithoutOperatorConnected_DerivesErrorAndKeepsReason() + { + _sm.BlockNodeStart("No node credential available"); + + Assert.Equal(OverallConnectionState.Error, _sm.Current.OverallState); + Assert.Equal(RoleConnectionState.Error, _sm.Current.NodeState); + Assert.Equal("No node credential available", _sm.Current.NodeError); + Assert.True(_sm.Current.NodeConnectionIntended); } // ─── Reset ─── @@ -420,10 +465,14 @@ public void Reset_ReturnsToIdle() [InlineData(RoleConnectionState.Connected, RoleConnectionState.RateLimited, false, OverallConnectionState.Ready)] // Node connecting is ignored when node mode is disabled → Ready (not Connecting). [InlineData(RoleConnectionState.Connected, RoleConnectionState.Connecting, false, OverallConnectionState.Ready)] - // Operator connected, node idle, node enabled → operator-only connected (fallthrough). - [InlineData(RoleConnectionState.Connected, RoleConnectionState.Idle, true, OverallConnectionState.Connected)] + // Operator connected, node idle, node enabled → intended node is blocked/degraded. + [InlineData(RoleConnectionState.Connected, RoleConnectionState.Idle, true, OverallConnectionState.Degraded)] // Node PairingRequired is reported regardless of nodeEnabled. [InlineData(RoleConnectionState.Connected, RoleConnectionState.PairingRequired, false, OverallConnectionState.PairingRequired)] + [InlineData(RoleConnectionState.Idle, RoleConnectionState.Connecting, true, OverallConnectionState.Connecting)] + [InlineData(RoleConnectionState.Idle, RoleConnectionState.Error, true, OverallConnectionState.Error)] + [InlineData(RoleConnectionState.Idle, RoleConnectionState.PairingRequired, true, OverallConnectionState.PairingRequired)] + [InlineData(RoleConnectionState.Idle, RoleConnectionState.Connected, true, OverallConnectionState.Connected)] public void DeriveOverall_ReturnsCorrectState( RoleConnectionState op, RoleConnectionState node, bool nodeEnabled, OverallConnectionState expected) { diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs index 8e82da628..d1e36d08d 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs @@ -308,6 +308,256 @@ public async Task HandshakeSucceeded_StartsManagerNodeConnector_WhenGateAllows() Assert.Equal("wss://remote.example", nodeConnector.LastGatewayUrl); } + [Fact] + public async Task HandshakeSucceeded_NodeModeEnabledMarksNodeConnectingBeforeEmitting() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Contains(snapshots, snapshot => + snapshot.OperatorState == RoleConnectionState.Connected && + snapshot.NodeState == RoleConnectionState.Connecting && + snapshot.OverallState == OverallConnectionState.Connecting); + Assert.DoesNotContain(snapshots, snapshot => + snapshot.OperatorState == RoleConnectionState.Connected && + snapshot.NodeState == RoleConnectionState.Idle && + snapshot.OverallState == OverallConnectionState.Degraded); + } + + [Fact] + public async Task HandshakeSucceeded_NodeModeEnabledMissingGatewayRecord_ReportsBlockedNode() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + + await manager.ConnectAsync("gw-remote"); + _registry.Remove("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Equal(0, nodeConnector.ConnectCount); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(OverallConnectionState.Degraded, manager.CurrentSnapshot.OverallState); + Assert.Contains("gateway record", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task HandshakeSucceeded_NodeModeEnabledMissingGatewayRecord_EmitsNoReadySnapshot() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectAsync("gw-remote"); + _registry.Remove("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.DoesNotContain(snapshots, snapshot => + snapshot.OperatorState == RoleConnectionState.Connected && + snapshot.OverallState == OverallConnectionState.Ready); + Assert.Contains(snapshots, snapshot => + snapshot.NodeState == RoleConnectionState.Error && + snapshot.NodeError?.Contains("gateway record", StringComparison.OrdinalIgnoreCase) == true); + } + + [Fact] + public async Task HandshakeSucceeded_NodeModeEnabledMissingConnector_EmitsNoReadySnapshot() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + isNodeEnabled: () => true); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.DoesNotContain(snapshots, snapshot => + snapshot.OperatorState == RoleConnectionState.Connected && + snapshot.OverallState == OverallConnectionState.Ready); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.Contains("no node connector", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ReconnectAfterNodeModeDisabled_ClearsNodeIntentAndDoesNotDeriveDegraded() + { + var nodeEnabled = true; + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => nodeEnabled); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + + nodeEnabled = false; + await manager.ReconnectAsync(); + await InvokeHandshakeSucceededAsync(manager); + + Assert.False(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(RoleConnectionState.Disabled, manager.CurrentSnapshot.NodeState); + Assert.Equal(OverallConnectionState.Ready, manager.CurrentSnapshot.OverallState); + } + + [Fact] + public async Task HandshakeSucceeded_NodeModeEnabledWithoutNodeCredential_DerivesDegradedBlockedNode() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = null; + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Equal(0, nodeConnector.ConnectCount); + Assert.Equal(RoleConnectionState.Connected, manager.CurrentSnapshot.OperatorState); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(OverallConnectionState.Degraded, manager.CurrentSnapshot.OverallState); + Assert.Contains("No node credential", manager.CurrentSnapshot.NodeError); + Assert.Null(manager.CurrentSnapshot.NodeCredentialSource); + } + + [Fact] + public async Task HandshakeSucceeded_NodeConnectorThrows_ReportsBlockedNode() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new ScriptedNodeConnector + { + ConnectAction = (_, _) => throw new InvalidOperationException("connector boom") + }; + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Equal(1, nodeConnector.ConnectCount); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.Equal(OverallConnectionState.Degraded, manager.CurrentSnapshot.OverallState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Contains("connector boom", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + Assert.Contains(snapshots, snapshot => + snapshot.NodeState == RoleConnectionState.Error && + snapshot.NodeError?.Contains("connector boom", StringComparison.OrdinalIgnoreCase) == true); + Assert.NotEqual(RoleConnectionState.Connecting, snapshots.Last().NodeState); + } + + [Fact] + public async Task HandshakeSucceeded_PreviousNodeDisconnectThrows_ReportsBlockedNode() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new ThrowingNodeDisconnectConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.Equal(OverallConnectionState.Degraded, manager.CurrentSnapshot.OverallState); + Assert.Contains("disconnect failed", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + Assert.Contains(snapshots, snapshot => + snapshot.NodeState == RoleConnectionState.Error && + snapshot.NodeError?.Contains("disconnect failed", StringComparison.OrdinalIgnoreCase) == true); + Assert.NotEqual(RoleConnectionState.Connecting, snapshots.Last().NodeState); + } + + [Fact] + public async Task BlockNodeStartAsync_StaleLifecycleGeneration_DoesNotOverwriteCurrentSnapshot() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance); + + await manager.ConnectAsync("gw-remote"); + var before = manager.CurrentSnapshot; + + await InvokeBlockNodeStartAsync( + manager, + "stale blocker", + expectedLifecycleGeneration: GetPrivateLong(manager, "_generation") + 1); + + Assert.Equal(before, manager.CurrentSnapshot); + } + [Fact] public async Task ConnectAsync_WithPersistedV2Requirement_SetsClientUseV2Signature() { @@ -481,10 +731,56 @@ private static async Task InvokeHandshakeSucceededAsync(GatewayConnectionManager "HandleHandshakeSucceededAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); Assert.NotNull(method); - var task = (Task)method!.Invoke(manager, [1L])!; + var task = (Task)method!.Invoke(manager, [GetPrivateLong(manager, "_generation")])!; + await task; + } + + private static async Task InvokeStartNodeConnectionCoreAsync( + GatewayConnectionManager manager, + long nodeGeneration) + { + var method = typeof(GatewayConnectionManager).GetMethod( + "StartNodeConnectionCoreAsync", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(method); + var task = (Task)method!.Invoke(manager, [GetPrivateLong(manager, "_generation"), nodeGeneration, CancellationToken.None])!; + return await task; + } + + private static async Task InvokeBlockNodeStartAsync( + GatewayConnectionManager manager, + string detail, + long? expectedLifecycleGeneration = null, + long? expectedNodeGeneration = null) + { + var method = typeof(GatewayConnectionManager).GetMethod( + "BlockNodeStartAsync", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(method); + var task = (Task)method!.Invoke( + manager, + [detail, CancellationToken.None, expectedLifecycleGeneration, expectedNodeGeneration])!; await task; } + private static void SetPrivateField(GatewayConnectionManager manager, string fieldName, object? value) + { + var field = typeof(GatewayConnectionManager).GetField( + fieldName, + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(field); + field!.SetValue(manager, value); + } + + private static long GetPrivateLong(GatewayConnectionManager manager, string fieldName) + { + var field = typeof(GatewayConnectionManager).GetField( + fieldName, + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(field); + return (long)field!.GetValue(manager)!; + } + private static async Task WaitUntilAsync(Func condition) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); @@ -587,6 +883,62 @@ public async Task ConnectNodeOnlyAsync_UsesNodeCredential_WhenOperatorCredential Assert.Equal(CredentialResolver.SourceNodeDeviceToken, manager.CurrentSnapshot.NodeCredentialSource); } + [Fact] + public async Task ConnectNodeOnlyAsync_MissingNodeCredential_ReportsBlockedNode() + { + SetupGateway("gw-1", "wss://test"); + _resolver.OperatorCredential = null; + _resolver.NodeCredential = null; + var node = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: node); + + await manager.ConnectNodeOnlyAsync("gw-1"); + + Assert.Equal(0, node.ConnectCount); + Assert.Empty(_factory.CreatedCredentials); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(OverallConnectionState.Error, manager.CurrentSnapshot.OverallState); + Assert.Contains("No node credential", manager.CurrentSnapshot.NodeError); + Assert.Null(manager.CurrentSnapshot.NodeCredentialSource); + } + + [Fact] + public async Task StartNodeConnectionCoreAsync_MissingActiveGatewayContext_ReportsBlockedNode() + { + SetupGateway("gw-1", "wss://test"); + _resolver.OperatorCredential = new GatewayCredential("operator-token", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-token", false, "test"); + var node = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: node, + shouldStartNodeConnection: (_, _) => false); + + await manager.ConnectAsync("gw-1"); + await InvokeHandshakeSucceededAsync(manager); + SetPrivateField(manager, "_activeGatewayRecordId", null); + + var started = await InvokeStartNodeConnectionCoreAsync( + manager, + GetPrivateLong(manager, "_nodeConnectionGeneration")); + + Assert.False(started); + Assert.Equal(0, node.ConnectCount); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(OverallConnectionState.Degraded, manager.CurrentSnapshot.OverallState); + Assert.Contains("no active gateway context", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ConnectNodeOnlyAsync_PreservesConnectedOperatorForNodeListRefresh() { @@ -728,6 +1080,44 @@ public async Task ConnectNodeOnlyAsync_StartsSshTunnel_WhenGatewayUsesTunnel() Assert.Equal(CredentialResolver.SourceNodeDeviceToken, manager.CurrentSnapshot.NodeCredentialSource); } + [Fact] + public async Task ConnectNodeOnlyAsync_TunnelStartFailure_ReportsBlockedNode() + { + _registry.AddOrUpdate(new GatewayRecord + { + Id = "gw-ssh", + Url = "wss://remote.example", + SshTunnel = new SshTunnelConfig("user", "host.example", 18789, 45678) + }); + _registry.SetActive("gw-ssh"); + _resolver.OperatorCredential = null; + _resolver.NodeCredential = new GatewayCredential( + "node-token", + IsBootstrapToken: false, + Source: CredentialResolver.SourceNodeDeviceToken); + var node = new CountingNodeConnector(); + var tunnel = new FailingTunnelManager(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: node, + tunnelManager: tunnel); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectNodeOnlyAsync("gw-ssh"); + + Assert.Equal(0, node.ConnectCount); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(OverallConnectionState.Error, manager.CurrentSnapshot.OverallState); + Assert.Contains("SSH tunnel", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + Assert.Contains(snapshots, snapshot => snapshot.NodeState == RoleConnectionState.Error); + Assert.NotEqual(RoleConnectionState.Connecting, snapshots.Last().NodeState); + } + [Fact] public async Task ConnectAsync_StartsSshTunnelAndUsesTunnelUrl_WhenGatewayUsesTunnel() { @@ -1347,6 +1737,39 @@ public async Task DisconnectAsync() public void Dispose() { } } + private sealed class ThrowingNodeDisconnectConnector : INodeConnector + { + public bool IsConnected => true; + public PairingStatus PairingStatus => PairingStatus.Paired; + public string? NodeDeviceId => "throwing-disconnect-node"; + public NodeConnectionMode Mode => NodeConnectionMode.Gateway; + +#pragma warning disable CS0067 // Events required by interface but not fired in tests + public event EventHandler? StatusChanged; + public event EventHandler? PairingStatusChanged; + public event EventHandler? DeviceTokenReceived; + public event EventHandler? ClientCreated; +#pragma warning restore CS0067 + + public Task ConnectAsync(string gatewayUrl, GatewayCredential credential, string identityPath, bool useV2Signature = false) + => Task.CompletedTask; + + public Task ConnectAsync( + string gatewayUrl, + GatewayCredential credential, + string identityPath, + bool useV2Signature, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return ConnectAsync(gatewayUrl, credential, identityPath, useV2Signature); + } + + public Task DisconnectAsync() => throw new InvalidOperationException("disconnect failed"); + + public void Dispose() { } + } + private sealed class CountingTunnelManager : ISshTunnelManager { public int StartCount { get; private set; } @@ -1372,6 +1795,19 @@ public Task StopAsync() public void Dispose() { } } + private sealed class FailingTunnelManager : ISshTunnelManager + { + public bool IsActive => false; + public string? LocalTunnelUrl => null; + + public Task StartAsync(SshTunnelConfig config, CancellationToken ct) => + throw new InvalidOperationException("tunnel failed"); + + public Task StopAsync() => Task.CompletedTask; + + public void Dispose() { } + } + /// /// Test connector that fires StatusChanged / PairingStatusChanged events synchronously /// so tests can drive the manager's state machine through realistic transitions. diff --git a/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs b/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs index 47719cdad..1d2642ab4 100644 --- a/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs @@ -84,6 +84,45 @@ public async Task SettingsGet_WithNoHandler_ReturnsError() Assert.False(res.Ok); } + [Fact] + public async Task SettingsSet_WithHandlerErrorPayload_ReturnsCommandError() + { + var cap = new AppCapability(NullLogger.Instance) + { + SettingsSetHandler = (_, _) => new { error = "MCP server startup failed" } + }; + var req = new NodeInvokeRequest + { + Id = "1", + Command = "app.settings.set", + Args = ParseArgs("{\"name\":\"EnableMcpServer\",\"value\":\"true\"}") + }; + + var res = await cap.ExecuteAsync(req); + + Assert.False(res.Ok); + Assert.Equal("MCP server startup failed", res.Error); + } + + [Fact] + public async Task SettingsSet_WithHandlerSuccessPayload_ReturnsData() + { + var cap = new AppCapability(NullLogger.Instance) + { + SettingsSetHandler = (name, _) => new { name, value = true } + }; + var req = new NodeInvokeRequest + { + Id = "1", + Command = "app.settings.set", + Args = ParseArgs("{\"name\":\"EnableMcpServer\",\"value\":\"true\"}") + }; + + var res = await cap.ExecuteAsync(req); + + Assert.True(res.Ok); + } + [Fact] public async Task UnknownCommand_ReturnsError() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index d8992295f..d06cb2196 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -86,7 +86,11 @@ public void McpOnlyStartup_DoesNotRequireGatewayCredentials() Assert.Contains("!_settings.EnableMcpServer || _settings.EnableNodeMode", method); Assert.Contains("EnsureNodeService(_settings)", method); Assert.Contains("StartLocalOnlyAsync()", method); + Assert.Contains("McpRuntimeStatePolicy.PlanStartupNotification", method); + Assert.Contains("ApplyMcpStartupNotificationPlan", method); Assert.Contains("WireAppCapabilityHandlers()", method); + AssertInOrder(method, "nodeService.StartLocalOnlyAsync()", "WireAppCapabilityHandlers()"); + AssertInOrder(method, "WireAppCapabilityHandlers()", "Started MCP-only node service without gateway connection"); var init = ExtractMethod(source, "InitializeGatewayClient"); AssertInOrder(init, "TryStartLocalMcpOnlyNode();", "Gateway URL not configured"); @@ -108,6 +112,124 @@ public void LegacyCredentialMigration_StaysRegistryBacked() Assert.DoesNotContain("BootstrapToken =", method); } + [Fact] + public void LifecycleStatus_IsWrittenFromManagerSnapshotOnly() + { + var source = ReadAppSources(); + var managerHandler = ExtractMethod(source, "OnManagerStateChanged"); + var rawHandler = ExtractMethod(source, "OnGatewayConnectionStatusChanged"); + + Assert.Contains("ConnectionStatusPresenter.ToLegacyStatus(snap)", managerHandler); + Assert.Contains("SyncConnectionToggle(mapped, snap.OverallState)", managerHandler); + Assert.Contains("_hubWindow?.UpdateTitleBarStatus(snap, mapped)", managerHandler); + Assert.Contains("_appState.Status = mapped", managerHandler); + Assert.DoesNotContain("_appState.Status =", rawHandler); + Assert.DoesNotContain("SyncConnectionToggle(status)", rawHandler); + Assert.DoesNotContain("RunHealthCheckAsync()", rawHandler); + Assert.DoesNotContain("TryConnectLocalNodeServiceAsync()", rawHandler); + } + + [Fact] + public void Dashboard_SurfacesSshTunnelConfigurationFailure() + { + var source = ReadAppSources(); + var method = ExtractMethod(source, "OpenDashboard"); + + Assert.Contains("if (!EnsureSshTunnelConfigured())", method); + Assert.Contains("_toastService?.ShowToast", method); + Assert.Contains("Check SSH tunnel settings and logs.", method); + } + + [Fact] + public void ConnectionIssueNotification_PrefersNodeOwnedFailuresBeforeGenericGatewayError() + { + var source = ReadAppSources(); + + AssertInOrder( + source, + "snapshot.NodeState == RoleConnectionState.PairingRequired", + "TryBuildNodeConnectionIssueNotification(snapshot", + "if (snapshot.OverallState == OverallConnectionState.Error)"); + Assert.Contains("TryBuildNodeConnectionIssueNotification", source); + Assert.Contains("snapshot.OperatorState == RoleConnectionState.Error", source); + } + + [Fact] + public void CommandCenter_UsesOverallStateBeforeLegacyStatus() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Services", "CommandCenterStateBuilder.cs")); + + AssertInOrder( + source, + "if (overallState == OpenClaw.Connection.OverallConnectionState.Degraded)", + "else if (_snapshot.Status == ConnectionStatus.Error)"); + Assert.Contains("_snapshot.Settings?.EnableMcpServer == true", source); + Assert.Contains("!string.IsNullOrWhiteSpace(mcpStartupError)", source); + } + + [Fact] + public void AppSettingsSet_AppliesSettingsSavedLifecycle() + { + var source = ReadAppSources(); + var method = ExtractMethod(source, "WireAppCapabilityHandlers"); + + AssertInOrder( + method, + "app.SettingsSetHandler = (name, value) =>", + "_settings.Save();", + "OnSettingsSaved(this, EventArgs.Empty);", + "McpRuntimeStatePolicy.GetSettingsSetError", + "return new { error = runtimeError };", + "return new { name, value = prop.GetValue(_settings) };"); + } + + [Fact] + public void OnSettingsSaved_AppliesMcpStartupNotificationPlan() + { + var source = ReadAppSources(); + var method = ExtractMethod(source, "OnSettingsSaved"); + + Assert.Contains("nodeService?.SetMcpEnabled(_settings.EnableMcpServer)", method); + Assert.Contains("McpRuntimeStatePolicy.PlanStartupNotification", method); + Assert.Contains("ApplyMcpStartupNotificationPlan", method); + AssertInOrder( + method, + "nodeService?.SetMcpEnabled(_settings.EnableMcpServer)", + "ApplyMcpStartupNotificationPlan", + "McpRuntimeStatePolicy.PlanStartupNotification"); + } + + [Fact] + public void AppStatus_ReportsNodeStateFromManagerSnapshot() + { + var source = ReadAppSources(); + var method = ExtractMethod(source, "WireAppCapabilityHandlers"); + + Assert.Contains("var snapshot = _connectionManager?.CurrentSnapshot;", method); + Assert.Contains("overallState = snapshot?.OverallState.ToString()", method); + Assert.Contains("operatorState = snapshot?.OperatorState.ToString()", method); + Assert.Contains("nodeState = snapshot?.NodeState.ToString()", method); + Assert.Contains("nodeConnected = snapshot?.NodeState == RoleConnectionState.Connected", method); + Assert.Contains("nodePaired = snapshot?.NodePairingStatus == PairingStatus.Paired", method); + Assert.Contains("nodePendingApproval = snapshot?.NodeState == RoleConnectionState.PairingRequired", method); + Assert.Contains("nodeError = snapshot?.NodeError", method); + Assert.Contains("operatorDeviceId = snapshot?.OperatorDeviceId", method); + } + + [Fact] + public void AppMenu_StatusItemIncludesManagerSnapshotState() + { + var source = ReadAppSources(); + var method = ExtractMethod(source, "WireAppCapabilityHandlers"); + + Assert.Contains("app.MenuHandler = () =>", method); + Assert.Contains("overallState = snapshot?.OverallState.ToString()", method); + Assert.Contains("nodeState = snapshot?.NodeState.ToString()", method); + Assert.Contains("nodeError = snapshot?.NodeError", method); + } + [Fact] public void Startup_NodeOnlyReconnect_UsesNodeCredentialAndLegacyIdentityFallback() { diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs index dc22d7340..0154d1cdb 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs @@ -230,6 +230,46 @@ public void NodeError_RemainsErrorDespiteStalePendingReapproval() AssertTrustDoesNotOverride(plan, NodeCardState.OnNodeError); } + [Fact] + public void IntendedNodeIdle_ProjectsAsDegradedNodeError_NotHealthy() + { + var plan = Build( + new GatewayConnectionSnapshot + { + OverallState = OverallConnectionState.Degraded, + OperatorState = RoleConnectionState.Connected, + NodeConnectionIntended = true, + NodeState = RoleConnectionState.Idle + }, + localNode: null); + + Assert.Equal(ConnectionPageMode.Cockpit, plan.Mode); + Assert.Equal(ConnectionAccent.Caution, plan.StripAccent); + Assert.Equal("Connection degraded", plan.StripHeadline); + Assert.Contains("node has not connected", plan.StripSub, StringComparison.OrdinalIgnoreCase); + Assert.Equal(NodeCardState.OnNodeError, plan.NodeCard); + } + + [Fact] + public void MissingNodeCredential_ProjectsAsBlockedNode_NotHealthy() + { + var plan = Build( + new GatewayConnectionSnapshot + { + OverallState = OverallConnectionState.Degraded, + OperatorState = RoleConnectionState.Connected, + NodeConnectionIntended = true, + NodeState = RoleConnectionState.Error, + NodeError = "No node credential available. Re-pair this PC." + }, + localNode: null); + + Assert.Equal(ConnectionPageMode.Cockpit, plan.Mode); + Assert.Equal(ConnectionAccent.Caution, plan.StripAccent); + Assert.Equal(NodeCardState.OnNodeError, plan.NodeCard); + Assert.Equal("No node credential available. Re-pair this PC.", plan.NodeErrorDetail); + } + private ConnectionPagePlan Build( PairingApprovalKind pairingApprovalKind, GatewayNodeInfo? localNode, diff --git a/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs index 17ed08c2e..4a2b106c8 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs @@ -1,4 +1,5 @@ using OpenClaw.Connection; +using OpenClaw.Shared; using OpenClawTray.Services; using System.Xml.Linq; using Xunit; @@ -30,6 +31,84 @@ public void Pill_ReadyAndConnected_BothReadConnected() ConnectionStatusPresenter.Pill(OverallConnectionState.Ready)); } + [Theory] + [InlineData(OverallConnectionState.Connected, ConnectionStatus.Connected)] + [InlineData(OverallConnectionState.Ready, ConnectionStatus.Connected)] + [InlineData(OverallConnectionState.Connecting, ConnectionStatus.Connecting)] + [InlineData(OverallConnectionState.Degraded, ConnectionStatus.Connected)] + [InlineData(OverallConnectionState.PairingRequired, ConnectionStatus.Error)] + [InlineData(OverallConnectionState.Error, ConnectionStatus.Error)] + [InlineData(OverallConnectionState.Idle, ConnectionStatus.Disconnected)] + public void ToLegacyStatus_PreservesOperatorLiveCompatibility( + OverallConnectionState overall, + ConnectionStatus expected) + { + Assert.Equal(expected, ConnectionStatusPresenter.ToLegacyStatus(overall)); + } + + [Fact] + public void PlainText_UsesDistinctBlockedLabels() + { + Assert.Equal("Degraded", ConnectionStatusPresenter.PlainText(OverallConnectionState.Degraded, ConnectionStatus.Connected)); + Assert.Equal("Pairing required", ConnectionStatusPresenter.PlainText(OverallConnectionState.PairingRequired, ConnectionStatus.Connecting)); + } + + [Theory] + [InlineData(OverallConnectionState.Connected, true)] + [InlineData(OverallConnectionState.Ready, true)] + [InlineData(OverallConnectionState.Degraded, true)] + [InlineData(OverallConnectionState.PairingRequired, true)] + [InlineData(OverallConnectionState.Connecting, true)] + [InlineData(OverallConnectionState.Error, false)] + [InlineData(OverallConnectionState.Idle, false)] + public void IsLiveOrPending_IncludesBlockedLiveStates(OverallConnectionState overall, bool expected) + { + Assert.Equal(expected, ConnectionStatusPresenter.IsLiveOrPending(overall, ConnectionStatus.Disconnected)); + } + + [Fact] + public void ToLegacyStatus_SnapshotKeepsOperatorChannelLiveDuringNodeDegraded() + { + var snapshot = new GatewayConnectionSnapshot + { + OverallState = OverallConnectionState.Degraded, + OperatorState = RoleConnectionState.Connected, + NodeConnectionIntended = true, + NodeState = RoleConnectionState.Error + }; + + Assert.Equal(ConnectionStatus.Connected, ConnectionStatusPresenter.ToLegacyStatus(snapshot)); + Assert.True(ConnectionStatusPresenter.IsOperatorChannelLive(snapshot)); + } + + [Fact] + public void ToLegacyStatus_SnapshotKeepsOperatorChannelLiveDuringNodePairing() + { + var snapshot = new GatewayConnectionSnapshot + { + OverallState = OverallConnectionState.PairingRequired, + OperatorState = RoleConnectionState.Connected, + NodeConnectionIntended = true, + NodeState = RoleConnectionState.PairingRequired + }; + + Assert.Equal(ConnectionStatus.Connected, ConnectionStatusPresenter.ToLegacyStatus(snapshot)); + } + + [Fact] + public void ToLegacyStatus_SnapshotKeepsOperatorPairingBlocked() + { + var snapshot = new GatewayConnectionSnapshot + { + OverallState = OverallConnectionState.PairingRequired, + OperatorState = RoleConnectionState.PairingRequired, + NodeState = RoleConnectionState.Idle + }; + + Assert.Equal(ConnectionStatus.Error, ConnectionStatusPresenter.ToLegacyStatus(snapshot)); + Assert.False(ConnectionStatusPresenter.IsOperatorChannelLive(snapshot)); + } + [Fact] public void NodeRow_NodeModeDisabled_ReadsDisabled_EvenWhenTransportConnected() { diff --git a/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs b/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs index 1abe6014c..6de4ad5c8 100644 --- a/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs +++ b/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs @@ -162,6 +162,18 @@ public void PermissionsPage_McpToggleRefreshesNodeStatus() Assert.Contains("UpdateNodeStatus()", toggle); } + [Fact] + public void NodeService_ExposesMcpStartupFailures() + { + var service = ReadSource("src", "OpenClaw.Tray.WinUI", "Services", "NodeService.cs"); + + Assert.Contains("public string? McpStartupError", service); + Assert.Contains("public void SetMcpStartupError", service); + Assert.Contains("SetMcpStartupFailure(ex, \"capability registration\")", service); + Assert.Contains("return false;", ExtractMethodBody(service, "bool StartMcpServer")); + Assert.Contains("MCP server startup failed: listener did not start.", service); + } + [Fact] public void NewNodeStateStrings_ExistInEnUsResources() { diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 4bc8b4b3a..11e2caba0 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -74,6 +74,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/Services/McpRuntimeStatePolicyTests.cs b/tests/OpenClaw.Tray.Tests/Services/McpRuntimeStatePolicyTests.cs new file mode 100644 index 000000000..9c87ea97c --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/Services/McpRuntimeStatePolicyTests.cs @@ -0,0 +1,80 @@ +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests.Services; + +public sealed class McpRuntimeStatePolicyTests +{ + [Fact] + public void PlanStartupNotification_WhenDisabled_Dismisses() + { + var plan = McpRuntimeStatePolicy.PlanStartupNotification( + enableMcpServer: false, + isMcpRunning: false, + startupError: "port busy"); + + Assert.False(plan.ShouldShow); + Assert.True(plan.ShouldDismiss); + Assert.Null(plan.Message); + } + + [Fact] + public void PlanStartupNotification_WhenEnabledAndHealthy_Dismisses() + { + var plan = McpRuntimeStatePolicy.PlanStartupNotification( + enableMcpServer: true, + isMcpRunning: true, + startupError: null); + + Assert.False(plan.ShouldShow); + Assert.True(plan.ShouldDismiss); + } + + [Fact] + public void PlanStartupNotification_WhenEnabledWithStartupError_ShowsError() + { + var plan = McpRuntimeStatePolicy.PlanStartupNotification( + enableMcpServer: true, + isMcpRunning: false, + startupError: "Port 8765 is already in use."); + + Assert.True(plan.ShouldShow); + Assert.False(plan.ShouldDismiss); + Assert.Equal("Port 8765 is already in use.", plan.Message); + } + + [Fact] + public void GetSettingsSetError_WhenEnablingMcpFails_ReturnsStartupError() + { + var error = McpRuntimeStatePolicy.GetSettingsSetError( + nameof(SettingsManager.EnableMcpServer), + true, + isMcpRunning: false, + startupError: "Access denied."); + + Assert.Equal("Access denied.", error); + } + + [Fact] + public void GetSettingsSetError_WhenEnablingMcpDoesNotStart_ReturnsDefaultError() + { + var error = McpRuntimeStatePolicy.GetSettingsSetError( + nameof(SettingsManager.EnableMcpServer), + true, + isMcpRunning: false, + startupError: null); + + Assert.Equal(McpRuntimeStatePolicy.DefaultStartupError, error); + } + + [Fact] + public void GetSettingsSetError_WhenDisablingMcp_ReturnsNull() + { + var error = McpRuntimeStatePolicy.GetSettingsSetError( + nameof(SettingsManager.EnableMcpServer), + false, + isMcpRunning: false, + startupError: "old error"); + + Assert.Null(error); + } +} diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs index 55ff92744..6926c4a54 100644 --- a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs @@ -1,4 +1,5 @@ using OpenClaw.Shared; +using OpenClaw.Connection; using OpenClawTray.Services; using System; using Xunit; @@ -18,9 +19,14 @@ private static TrayMenuSnapshot Base( GatewayUsageInfo? usage = null, DateTime? lastUpdated = null, PairingListInfo? nodePairList = null, - DevicePairingListInfo? devicePairList = null) => new() + DevicePairingListInfo? devicePairList = null, + OverallConnectionState? overallState = null, + bool isMcpRunning = false, + string? mcpStartupError = null, + SettingsManager? settings = null) => new() { CurrentStatus = status, + OverallState = overallState, AuthFailureMessage = authFailure, GatewayUrl = gatewayUrl, GatewaySelf = null, @@ -36,10 +42,12 @@ private static TrayMenuSnapshot Base( Usage = usage, UsageStatus = null, UsageCost = null, - Settings = null, + Settings = settings, SetupMenuLabel = "Reconfigure...", ShowSetupMenuEntry = true, LastUpdated = lastUpdated, + IsMcpRunning = isMcpRunning, + McpStartupError = mcpStartupError, }; private static TrayDashboardSummary Build(TrayMenuSnapshot snapshot) => @@ -84,6 +92,136 @@ public void Error_IsCriticalSeverity() Assert.Equal("Connection error", summary.Headline); } + [Fact] + public void DegradedOverall_IsCautionAndNotConnected() + { + var summary = Build(Base( + ConnectionStatus.Error, + overallState: OverallConnectionState.Degraded)); + + Assert.Equal(TrayHealthSeverity.Caution, summary.Severity); + Assert.Equal("Connection degraded", summary.Headline); + } + + [Fact] + public void PairingRequiredOverall_IsDistinctFromConnecting() + { + var summary = Build(Base( + ConnectionStatus.Error, + overallState: OverallConnectionState.PairingRequired)); + + Assert.Equal(TrayHealthSeverity.Caution, summary.Severity); + Assert.Equal("Pairing required", summary.Headline); + } + + [Fact] + public void LocalMcpOnly_IsExplicitNotGatewayConnected() + { + var settingsDirectory = Path.Combine(Path.GetTempPath(), "openclaw-dashboard-mcp-" + Guid.NewGuid().ToString("N")); + try + { + var settings = new SettingsManager(settingsDirectory) + { + EnableMcpServer = true, + EnableNodeMode = false + }; + + var summary = Build(Base( + ConnectionStatus.Disconnected, + isMcpRunning: true, + settings: settings)); + + Assert.Equal(TrayHealthSeverity.Ok, summary.Severity); + Assert.Equal("Local MCP only", summary.Headline); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(settingsDirectory, recursive: true); } catch { } + } + } + + [Fact] + public void LocalMcpOnly_DoesNotMaskDegradedGatewayLifecycle() + { + var settingsDirectory = Path.Combine(Path.GetTempPath(), "openclaw-dashboard-mcp-" + Guid.NewGuid().ToString("N")); + try + { + var settings = new SettingsManager(settingsDirectory) + { + EnableMcpServer = true, + EnableNodeMode = false + }; + + var summary = Build(Base( + ConnectionStatus.Error, + overallState: OverallConnectionState.Degraded, + isMcpRunning: true, + settings: settings)); + + Assert.Equal(TrayHealthSeverity.Caution, summary.Severity); + Assert.Equal("Connection degraded", summary.Headline); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(settingsDirectory, recursive: true); } catch { } + } + } + + [Fact] + public void McpStartupError_OutranksDisconnected() + { + var settingsDirectory = Path.Combine(Path.GetTempPath(), "openclaw-dashboard-mcp-" + Guid.NewGuid().ToString("N")); + try + { + var settings = new SettingsManager(settingsDirectory) + { + EnableMcpServer = true + }; + + var summary = Build(Base( + ConnectionStatus.Disconnected, + mcpStartupError: "Port 8765 is already in use.", + settings: settings)); + + Assert.Equal(TrayHealthSeverity.Critical, summary.Severity); + Assert.Equal("Local MCP failed", summary.Headline); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(settingsDirectory, recursive: true); } catch { } + } + } + + [Fact] + public void StaleMcpStartupError_IsIgnoredWhenMcpDisabled() + { + var settingsDirectory = Path.Combine(Path.GetTempPath(), "openclaw-dashboard-mcp-" + Guid.NewGuid().ToString("N")); + try + { + var settings = new SettingsManager(settingsDirectory) + { + EnableMcpServer = false + }; + + var summary = Build(Base( + ConnectionStatus.Connected, + overallState: OverallConnectionState.Ready, + mcpStartupError: "stale failure", + settings: settings)); + + Assert.Equal(TrayHealthSeverity.Ok, summary.Severity); + Assert.Equal("Connected", summary.Headline); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(settingsDirectory, recursive: true); } catch { } + } + } + [Fact] public void AuthFailure_OverridesConnectedWithCriticalSeverity() { diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs index b97b063d7..897a29ab9 100644 --- a/tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs @@ -1,4 +1,5 @@ using OpenClaw.Shared; +using OpenClaw.Connection; using OpenClawTray.Helpers; using OpenClawTray.Services; using System; @@ -97,6 +98,107 @@ public void Build_StatusNotConnected_CountsOneWarning() Assert.Contains("Warnings 1", result); } + [Fact] + public void Build_DegradedOverall_DoesNotReadConnected() + { + var snapshot = BaseConnected() with + { + OverallState = OverallConnectionState.Degraded + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("OpenClaw Tray - Degraded", result); + Assert.Contains("Warnings 1", result); + } + + [Fact] + public void Build_LocalMcpOnly_IsExplicit() + { + var settings = new SettingsManager(_tempDir) + { + EnableMcpServer = true, + EnableNodeMode = false + }; + var snapshot = new TrayStateSnapshot + { + Status = ConnectionStatus.Disconnected, + Settings = settings, + IsMcpRunning = true, + LastCheckTime = FixedTime + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("OpenClaw Tray - Local MCP only", result); + Assert.Contains("Warnings 1", result); + } + + [Fact] + public void Build_LocalMcpOnly_DoesNotMaskDegradedGatewayLifecycle() + { + var settings = new SettingsManager(_tempDir) + { + EnableMcpServer = true, + EnableNodeMode = false + }; + var snapshot = new TrayStateSnapshot + { + Status = ConnectionStatus.Error, + OverallState = OverallConnectionState.Degraded, + Settings = settings, + IsMcpRunning = true, + LastCheckTime = FixedTime + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("OpenClaw Tray - Degraded", result); + Assert.DoesNotContain("Local MCP only", result); + } + + [Fact] + public void Build_McpStartupError_IsExplicit() + { + var settings = new SettingsManager(_tempDir) + { + EnableMcpServer = true + }; + var snapshot = new TrayStateSnapshot + { + Status = ConnectionStatus.Disconnected, + Settings = settings, + McpStartupError = "Port 8765 is already in use.", + LastCheckTime = FixedTime + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("OpenClaw Tray - Local MCP failed", result); + Assert.Contains("Warnings 2", result); + } + + [Fact] + public void Build_StaleMcpStartupError_IsIgnoredWhenMcpDisabled() + { + var settings = new SettingsManager(_tempDir) + { + EnableMcpServer = false + }; + var snapshot = BaseConnected(authFailure: null) with + { + OverallState = OverallConnectionState.Ready, + Settings = settings, + McpStartupError = "stale failure" + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("OpenClaw Tray - Connected", result); + Assert.Contains("Warnings 1", result); + Assert.DoesNotContain("Local MCP failed", result); + } + [Fact] public void Build_AuthFailureMessage_CountsOneWarning() {