diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index a6f96acc522..1935976e6c6 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -18,22 +18,6 @@ private struct GatewayRelayIdentityResponse: Decodable { let publicKey: String } -private struct NodePresenceAlivePayload: Encodable, Sendable { - let displayName: String - let version: String - let platform: String - let deviceFamily: String - let modelIdentifier: String - let trigger: String - let pushTransport: String - let sentAtMs: Int -} - -private struct NodeEventRequestPayload: Encodable, Sendable { - let event: String - let payloadJSON: String? -} - // Ensures notification requests return promptly even if the system prompt blocks. private final class NotificationInvokeLatch: @unchecked Sendable { private let lock = NSLock() @@ -623,9 +607,6 @@ final class NodeAppModel { private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex" private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() - private static let backgroundAliveBeaconLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs" - private static let backgroundAliveBeaconLastTriggerKey = "gateway.backgroundAlive.lastTrigger" - private static let backgroundAliveBeaconMinimumIntervalMs = 10 * 60 * 1000 private func refreshBrandingFromGateway() async { do { @@ -3116,9 +3097,7 @@ extension NodeAppModel { return handled } - let result = await self.performBackgroundAliveBeaconIfNeeded( - wakeId: wakeId, - trigger: pushKind) + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) let outcomeMessage = "Silent push outcome wakeId=\(wakeId) " + "applied=\(result.applied) " @@ -3136,9 +3115,7 @@ extension NodeAppModel { + "backgrounded=\(self.isBackgrounded) " + "autoReconnect=\(self.gatewayAutoReconnectEnabled)" self.pushWakeLogger.info("\(receivedMessage, privacy: .public)") - let result = await self.performBackgroundAliveBeaconIfNeeded( - wakeId: wakeId, - trigger: trigger) + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) let outcomeMessage = "Background refresh wake outcome wakeId=\(wakeId) " + "applied=\(result.applied) " @@ -3174,9 +3151,7 @@ extension NodeAppModel { + "backgrounded=\(self.isBackgrounded) " + "autoReconnect=\(self.gatewayAutoReconnectEnabled)" self.locationWakeLogger.info("\(beginMessage, privacy: .public)") - let result = await self.performBackgroundAliveBeaconIfNeeded( - wakeId: wakeId, - trigger: "significant_location") + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) let triggerMessage = "Location wake trigger wakeId=\(wakeId) " + "applied=\(result.applied) " @@ -3765,79 +3740,10 @@ extension NodeAppModel { return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250) } - private func publishBackgroundAliveBeacon(trigger: String, wakeId: String) async -> Bool { - let normalizedTrigger = trigger.trimmingCharacters(in: .whitespacesAndNewlines) - let beaconTrigger = normalizedTrigger.isEmpty ? "background" : normalizedTrigger - let displayName = NodeDisplayName.resolve( - existing: UserDefaults.standard.string(forKey: "node.displayName"), - deviceName: UIDevice.current.name, - interfaceIdiom: UIDevice.current.userInterfaceIdiom) - let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport - let payload = NodePresenceAlivePayload( - displayName: displayName, - version: DeviceInfoHelper.appVersion(), - platform: DeviceInfoHelper.platformString(), - deviceFamily: DeviceInfoHelper.deviceFamily(), - modelIdentifier: DeviceInfoHelper.modelIdentifier(), - trigger: beaconTrigger, - pushTransport: usesRelayTransport ? PushTransportMode.relay.rawValue : PushTransportMode.direct.rawValue, - sentAtMs: Int(Date().timeIntervalSince1970 * 1000)) - - do { - let payloadJSON = try Self.encodePayload(payload) - let requestJSON = try Self.encodePayload(NodeEventRequestPayload( - event: "node.presence.alive", - payloadJSON: payloadJSON)) - _ = try await self.nodeGateway.request( - method: "node.event", - paramsJSON: requestJSON, - timeoutSeconds: 8) - self.pushWakeLogger.info( - "Wake alive beacon acknowledged wakeId=\(wakeId, privacy: .public) trigger=\(beaconTrigger, privacy: .public)") - return true - } catch { - self.pushWakeLogger.error( - "Wake alive beacon failed wakeId=\(wakeId, privacy: .public) trigger=\(beaconTrigger, privacy: .public) error=\(error.localizedDescription, privacy: .public)") - return false - } - } - - private func recordBackgroundAliveBeaconSuccess(trigger: String, nowMs: Int) { - let normalizedTrigger = trigger.trimmingCharacters(in: .whitespacesAndNewlines) - let defaults = UserDefaults.standard - defaults.set(nowMs, forKey: Self.backgroundAliveBeaconLastSuccessAtMsKey) - defaults.set(normalizedTrigger.isEmpty ? "background" : normalizedTrigger, forKey: Self.backgroundAliveBeaconLastTriggerKey) - } - - nonisolated private static func shouldThrottleBackgroundAliveBeacon( - lastSuccessAtMs: Int?, - nowMs: Int, - minimumIntervalMs: Int) -> Bool - { - guard let lastSuccessAtMs else { return false } - guard nowMs >= lastSuccessAtMs else { return false } - return nowMs - lastSuccessAtMs < minimumIntervalMs - } - - nonisolated private static func shouldSkipBackgroundAliveBeaconBecauseOfRecentSuccess( - lastSuccessAtMs: Int?, - nowMs: Int, - minimumIntervalMs: Int, - gatewayConnected: Bool) -> Bool - { - gatewayConnected && self.shouldThrottleBackgroundAliveBeacon( - lastSuccessAtMs: lastSuccessAtMs, - nowMs: nowMs, - minimumIntervalMs: minimumIntervalMs) - } - - private func performBackgroundAliveBeaconIfNeeded( - wakeId: String, - trigger: String + private func reconnectGatewaySessionsForSilentPushIfNeeded( + wakeId: String ) async -> SilentPushWakeAttemptResult { let startedAt = Date() - let normalizedTrigger = trigger.trimmingCharacters(in: .whitespacesAndNewlines) - let beaconTrigger = normalizedTrigger.isEmpty ? "background" : normalizedTrigger let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000) return SilentPushWakeAttemptResult( @@ -3859,48 +3765,18 @@ extension NodeAppModel { return makeResult(false, "no_active_gateway_config") } - let nowMs = Int(Date().timeIntervalSince1970 * 1000) - let lastSuccessAtMs = UserDefaults.standard.object(forKey: Self.backgroundAliveBeaconLastSuccessAtMsKey) as? Int - let gatewayConnected = await self.isGatewayConnected() - if Self.shouldSkipBackgroundAliveBeaconBecauseOfRecentSuccess( - lastSuccessAtMs: lastSuccessAtMs, - nowMs: nowMs, - minimumIntervalMs: Self.backgroundAliveBeaconMinimumIntervalMs, - gatewayConnected: gatewayConnected) - { - self.pushWakeLogger.info( - "Wake no-op wakeId=\(wakeId, privacy: .public): recent alive beacon already succeeded trigger=\(beaconTrigger, privacy: .public)") - return makeResult(false, "recent_success") - } - - if !gatewayConnected { - self.pushWakeLogger.info( - "Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public) trigger=\(beaconTrigger, privacy: .public)") - self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)") - await self.operatorGateway.disconnect() - await self.nodeGateway.disconnect() - self.operatorConnected = false - self.gatewayConnected = false - self.gatewayStatusText = "Reconnecting…" - self.talkMode.updateGatewayConnected(false) - self.applyGatewayConnectConfig(cfg) - self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)") - - let connected = await self.waitForGatewayConnection(timeoutMs: 12_000, pollMs: 250) - guard connected else { - self.pushWakeLogger.info( - "Wake reconnect timeout wakeId=\(wakeId, privacy: .public) trigger=\(beaconTrigger, privacy: .public)") - return makeResult(false, "reconnect_timeout") - } - } else { - self.grantBackgroundReconnectLease(seconds: 15, reason: "wake_connected_\(wakeId)") - } - - guard await self.publishBackgroundAliveBeacon(trigger: beaconTrigger, wakeId: wakeId) else { - return makeResult(false, "beacon_failed") - } - self.recordBackgroundAliveBeaconSuccess(trigger: beaconTrigger, nowMs: nowMs) - return makeResult(true, "beacon_acknowledged") + self.pushWakeLogger.info( + "Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)") + self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)") + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + self.operatorConnected = false + self.gatewayConnected = false + self.gatewayStatusText = "Reconnecting…" + self.talkMode.updateGatewayConnected(false) + self.applyGatewayConnectConfig(cfg) + self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)") + return makeResult(true, "reconnect_triggered") } } @@ -4250,15 +4126,6 @@ extension NodeAppModel { self.gatewayConnected = connected } - func _test_setBackgrounded(_ backgrounded: Bool) { - self.isBackgrounded = backgrounded - } - - func _test_performBackgroundAliveBeacon(trigger: String, wakeId: String) async -> Bool { - let result = await self.performBackgroundAliveBeaconIfNeeded(wakeId: wakeId, trigger: trigger) - return result.applied - } - func _test_applyPendingForegroundNodeActions( _ actions: [(id: String, command: String, paramsJSON: String?)]) async { @@ -4381,30 +4248,6 @@ extension NodeAppModel { hasStoredOperatorToken: hasStoredOperatorToken) } - nonisolated static func _test_shouldThrottleBackgroundAliveBeacon( - lastSuccessAtMs: Int?, - nowMs: Int, - minimumIntervalMs: Int) -> Bool - { - self.shouldThrottleBackgroundAliveBeacon( - lastSuccessAtMs: lastSuccessAtMs, - nowMs: nowMs, - minimumIntervalMs: minimumIntervalMs) - } - - nonisolated static func _test_shouldSkipBackgroundAliveBeaconBecauseOfRecentSuccess( - lastSuccessAtMs: Int?, - nowMs: Int, - minimumIntervalMs: Int, - gatewayConnected: Bool) -> Bool - { - self.shouldSkipBackgroundAliveBeaconBecauseOfRecentSuccess( - lastSuccessAtMs: lastSuccessAtMs, - nowMs: nowMs, - minimumIntervalMs: minimumIntervalMs, - gatewayConnected: gatewayConnected) - } - nonisolated static func _test_shouldRequestOperatorApprovalScope( token: String?, password: String?, diff --git a/apps/ios/Tests/NodeBackgroundAliveBeaconTests.swift b/apps/ios/Tests/NodeBackgroundAliveBeaconTests.swift deleted file mode 100644 index e0b9724e5de..00000000000 --- a/apps/ios/Tests/NodeBackgroundAliveBeaconTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite struct NodeBackgroundAliveBeaconTests { - @Test func doesNotThrottleWithoutPriorSuccess() { - #expect( - NodeAppModel._test_shouldThrottleBackgroundAliveBeacon( - lastSuccessAtMs: nil, - nowMs: 10_000, - minimumIntervalMs: 60_000) == false) - } - - @Test func throttlesWithinMinimumInterval() { - #expect( - NodeAppModel._test_shouldThrottleBackgroundAliveBeacon( - lastSuccessAtMs: 100_000, - nowMs: 120_000, - minimumIntervalMs: 60_000)) - } - - @Test func doesNotThrottleAtBoundaryOrAfter() { - #expect( - NodeAppModel._test_shouldThrottleBackgroundAliveBeacon( - lastSuccessAtMs: 100_000, - nowMs: 160_000, - minimumIntervalMs: 60_000) == false) - #expect( - NodeAppModel._test_shouldThrottleBackgroundAliveBeacon( - lastSuccessAtMs: 100_000, - nowMs: 200_000, - minimumIntervalMs: 60_000) == false) - } - - @Test func doesNotThrottleWhenClockMovesBackward() { - #expect( - NodeAppModel._test_shouldThrottleBackgroundAliveBeacon( - lastSuccessAtMs: 200_000, - nowMs: 100_000, - minimumIntervalMs: 60_000) == false) - } - - @Test func recentSuccessDoesNotSkipReconnectWhenGatewayIsDisconnected() { - #expect( - NodeAppModel._test_shouldSkipBackgroundAliveBeaconBecauseOfRecentSuccess( - lastSuccessAtMs: 100_000, - nowMs: 120_000, - minimumIntervalMs: 60_000, - gatewayConnected: false) == false) - #expect( - NodeAppModel._test_shouldSkipBackgroundAliveBeaconBecauseOfRecentSuccess( - lastSuccessAtMs: 100_000, - nowMs: 120_000, - minimumIntervalMs: 60_000, - gatewayConnected: true)) - } -} diff --git a/apps/ios/Tests/NodeBackgroundAliveE2ETests.swift b/apps/ios/Tests/NodeBackgroundAliveE2ETests.swift deleted file mode 100644 index abcdc25059d..00000000000 --- a/apps/ios/Tests/NodeBackgroundAliveE2ETests.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation -import OpenClawKit -import Testing -@testable import OpenClaw - -@Suite(.serialized) struct NodeBackgroundAliveE2ETests { - private struct NodeListResult: Decodable { - var nodes: [NodeSummary] - } - - private struct NodeSummary: Decodable { - var nodeId: String - var clientId: String? - var connected: Bool? - var lastSeenAtMs: Int? - var lastSeenReason: String? - } - - private static let enabledEnvKey = "OPENCLAW_IOS_BACKGROUND_ALIVE_E2E" - private static let urlEnvKey = "OPENCLAW_IOS_BACKGROUND_ALIVE_E2E_URL" - - @Test @MainActor func reconnectsAndPublishesAliveBeaconAgainstLocalGateway() async throws { - guard ProcessInfo.processInfo.environment[Self.enabledEnvKey] == "1" else { return } - - let gatewayURL = URL( - string: ProcessInfo.processInfo.environment[Self.urlEnvKey] ?? "ws://127.0.0.1:18789")! - let appModel = NodeAppModel() - let operatorSession = GatewayNodeSession() - defer { - appModel.disconnectGateway() - Task { - await operatorSession.disconnect() - } - } - - try await operatorSession.connect( - url: gatewayURL, - token: nil, - bootstrapToken: nil, - password: nil, - connectOptions: GatewayConnectOptions( - role: "operator", - scopes: ["operator.admin", "operator.read"], - caps: [], - commands: [], - permissions: [:], - clientId: "ios-background-alive-e2e-operator", - clientMode: "ui", - clientDisplayName: "iOS Background Alive E2E"), - sessionBox: nil, - onConnected: {}, - onDisconnected: { _ in }, - onInvoke: { req in - BridgeInvokeResponse( - id: req.id, - ok: false, - error: OpenClawNodeError( - code: .invalidRequest, - message: "operator session does not handle node invokes")) - }) - - let nodeOptions = GatewayConnectOptions( - role: "node", - scopes: [], - caps: [OpenClawCapability.device.rawValue], - commands: [OpenClawDeviceCommand.status.rawValue], - permissions: [:], - clientId: "ios-background-alive-e2e-node", - clientMode: "node", - clientDisplayName: "iOS Background Alive E2E") - appModel.applyGatewayConnectConfig( - GatewayConnectConfig( - url: gatewayURL, - stableID: "ios-background-alive-e2e", - tls: nil, - token: nil, - bootstrapToken: nil, - password: nil, - nodeOptions: nodeOptions)) - - let initialNode = try await Self.waitForNode( - operatorSession: operatorSession, - clientId: nodeOptions.clientId, - timeoutSeconds: 12) - let initialLastSeenAtMs = initialNode.lastSeenAtMs ?? 0 - - appModel._test_setBackgrounded(true) - await appModel.gatewaySession.disconnect() - appModel._test_setGatewayConnected(false) - - let applied = await appModel._test_performBackgroundAliveBeacon( - trigger: "simulator_e2e", - wakeId: "sim-e2e") - #expect(applied) - - let updatedNode = try await Self.waitForNode( - operatorSession: operatorSession, - clientId: nodeOptions.clientId, - timeoutSeconds: 12, - predicate: { node in - node.lastSeenReason == "simulator_e2e" && (node.lastSeenAtMs ?? 0) > initialLastSeenAtMs - }) - #expect(updatedNode.lastSeenReason == "simulator_e2e") - #expect((updatedNode.lastSeenAtMs ?? 0) > initialLastSeenAtMs) - } - - private static func waitForNode( - operatorSession: GatewayNodeSession, - clientId: String, - timeoutSeconds: Double, - predicate: ((NodeSummary) -> Bool)? = nil - ) async throws -> NodeSummary { - let deadline = Date().addingTimeInterval(timeoutSeconds) - while Date() < deadline { - let payload = try await operatorSession.request(method: "node.list", paramsJSON: "{}", timeoutSeconds: 8) - let decoded = try JSONDecoder().decode(NodeListResult.self, from: payload) - if let match = decoded.nodes.first(where: { node in - node.clientId == clientId && (predicate?(node) ?? true) - }) { - return match - } - try await Task.sleep(nanoseconds: 250_000_000) - } - throw NSError( - domain: "NodeBackgroundAliveE2E", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Timed out waiting for node \(clientId)"]) - } -} diff --git a/src/gateway/node-catalog.test.ts b/src/gateway/node-catalog.test.ts index 7b329724c42..2faf60e793c 100644 --- a/src/gateway/node-catalog.test.ts +++ b/src/gateway/node-catalog.test.ts @@ -94,8 +94,6 @@ describe("gateway/node-catalog", () => { commands: ["system.run"], createdAtMs: 1, approvedAtMs: 100, - lastSeenAtMs: 111, - lastSeenReason: "bg_app_refresh", }, ], connectedNodes: [ @@ -137,8 +135,6 @@ describe("gateway/node-catalog", () => { pathEnv: "/usr/bin:/bin", approvedAtMs: 100, connectedAtMs, - lastSeenAtMs: connectedAtMs, - lastSeenReason: "connect", paired: true, connected: true, }), @@ -177,8 +173,6 @@ describe("gateway/node-catalog", () => { commands: ["system.run"], createdAtMs: 1, approvedAtMs: 123, - lastSeenAtMs: 456, - lastSeenReason: "silent_push", }, ], connectedNodes: [], @@ -199,102 +193,6 @@ describe("gateway/node-catalog", () => { caps: ["system"], commands: ["system.run"], approvedAtMs: 123, - lastSeenAtMs: 456, - lastSeenReason: "silent_push", - paired: true, - connected: false, - }), - ); - }); - - it("prefers the newest last-seen source consistently", () => { - const catalog = createKnownNodeCatalog({ - pairedDevices: [ - { - deviceId: "ios-1", - publicKey: "public-key", - displayName: "iPhone", - clientId: "openclaw-ios", - clientMode: "node", - role: "node", - roles: ["node"], - lastSeenAtMs: 900, - lastSeenReason: "silent_push", - tokens: { - node: { - token: "device-token", - role: "node", - scopes: [], - createdAtMs: 1, - }, - }, - createdAtMs: 1, - approvedAtMs: 99, - }, - ], - pairedNodes: [ - { - nodeId: "ios-1", - token: "node-token", - platform: "ios", - caps: ["device"], - commands: ["device.status"], - createdAtMs: 1, - approvedAtMs: 123, - lastSeenAtMs: 800, - lastSeenReason: "bg_app_refresh", - }, - ], - connectedNodes: [], - }); - - expect(getKnownNode(catalog, "ios-1")).toEqual( - expect.objectContaining({ - nodeId: "ios-1", - lastSeenAtMs: 900, - lastSeenReason: "silent_push", - paired: true, - connected: false, - }), - ); - }); - - it("surfaces device-pair last-seen metadata when node pairing is absent", () => { - const catalog = createKnownNodeCatalog({ - pairedDevices: [ - { - deviceId: "ios-1", - publicKey: "public-key", - displayName: "iPhone", - clientId: "openclaw-ios", - clientMode: "node", - role: "node", - roles: ["node"], - lastSeenAtMs: 789, - lastSeenReason: "background-alive-test", - tokens: { - node: { - token: "device-token", - role: "node", - scopes: [], - createdAtMs: 1, - }, - }, - createdAtMs: 1, - approvedAtMs: 99, - }, - ], - pairedNodes: [], - connectedNodes: [], - }); - - expect(getKnownNode(catalog, "ios-1")).toEqual( - expect.objectContaining({ - nodeId: "ios-1", - clientId: "openclaw-ios", - clientMode: "node", - lastSeenAtMs: 789, - lastSeenReason: "background-alive-test", paired: true, connected: false, }), diff --git a/src/gateway/node-catalog.ts b/src/gateway/node-catalog.ts index c7900513ed6..8ceda8071de 100644 --- a/src/gateway/node-catalog.ts +++ b/src/gateway/node-catalog.ts @@ -12,8 +12,6 @@ export type KnownNodeDevicePairingSource = { clientMode?: string; remoteIp?: string; approvedAtMs?: number; - lastSeenAtMs?: number; - lastSeenReason?: string; }; export type KnownNodeApprovedSource = { @@ -30,8 +28,6 @@ export type KnownNodeApprovedSource = { commands: string[]; permissions?: Record; approvedAtMs?: number; - lastSeenAtMs?: number; - lastSeenReason?: string; }; export type KnownNodeEntry = { @@ -71,8 +67,6 @@ function buildDevicePairingSource(entry: PairedDevice): KnownNodeDevicePairingSo clientMode: entry.clientMode, remoteIp: entry.remoteIp, approvedAtMs: entry.approvedAtMs, - lastSeenAtMs: entry.lastSeenAtMs, - lastSeenReason: entry.lastSeenReason, }; } @@ -91,41 +85,6 @@ function buildApprovedNodeSource(entry: NodePairingPairedNode): KnownNodeApprove commands: entry.commands ?? [], permissions: entry.permissions, approvedAtMs: entry.approvedAtMs, - lastSeenAtMs: entry.lastSeenAtMs, - lastSeenReason: entry.lastSeenReason, - }; -} - -function resolveEffectiveLastSeen(entry: { - devicePairing?: KnownNodeDevicePairingSource; - nodePairing?: KnownNodeApprovedSource; - live?: NodeSession; -}): Pick { - const { devicePairing, nodePairing, live } = entry; - const candidates = [ - live?.connectedAtMs ? { ts: live.connectedAtMs, reason: "connect" } : null, - nodePairing?.lastSeenAtMs - ? { ts: nodePairing.lastSeenAtMs, reason: nodePairing.lastSeenReason } - : null, - devicePairing?.lastSeenAtMs - ? { ts: devicePairing.lastSeenAtMs, reason: devicePairing.lastSeenReason } - : null, - ].filter((candidate) => candidate !== null); - const winner = candidates.reduce<{ - ts: number; - reason?: string; - } | null>((best, candidate) => { - if (!candidate) { - return best; - } - if (!best || candidate.ts > best.ts) { - return candidate; - } - return best; - }, null); - return { - lastSeenAtMs: winner?.ts, - lastSeenReason: winner?.reason, }; } @@ -136,7 +95,6 @@ function buildEffectiveKnownNode(entry: { live?: NodeSession; }): NodeListNode { const { nodeId, devicePairing, nodePairing, live } = entry; - const { lastSeenAtMs, lastSeenReason } = resolveEffectiveLastSeen(entry); return { nodeId, displayName: live?.displayName ?? nodePairing?.displayName ?? devicePairing?.displayName, @@ -157,8 +115,6 @@ function buildEffectiveKnownNode(entry: { permissions: live?.permissions ?? nodePairing?.permissions, connectedAtMs: live?.connectedAtMs, approvedAtMs: nodePairing?.approvedAtMs ?? devicePairing?.approvedAtMs, - lastSeenAtMs, - lastSeenReason, paired: Boolean(devicePairing ?? nodePairing), connected: Boolean(live), }; diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 84220587003..342e98d6d87 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -1131,20 +1131,10 @@ export const nodeHandlers: GatewayRequestHandlers = { loadGatewayModelCatalog: context.loadGatewayModelCatalog, logGateway: { warn: context.logGateway.warn }, }; - const nodePairingIds = new Set([nodeId]); - const instanceId = normalizeOptionalString(client?.connect?.client?.instanceId) ?? ""; - if (instanceId) { - nodePairingIds.add(instanceId); - } - await handleNodeEvent( - nodeContext, - nodeId, - { - event: p.event, - payloadJSON, - }, - { nodePairingIds: [...nodePairingIds] }, - ); + await handleNodeEvent(nodeContext, nodeId, { + event: p.event, + payloadJSON, + }); respond(true, { ok: true }, undefined); }); }, diff --git a/src/gateway/server-node-events.runtime.ts b/src/gateway/server-node-events.runtime.ts index ab05662c4d4..8ba881dace9 100644 --- a/src/gateway/server-node-events.runtime.ts +++ b/src/gateway/server-node-events.runtime.ts @@ -7,8 +7,6 @@ export { loadConfig } from "../config/config.js"; export { updateSessionStore } from "../config/sessions.js"; export { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; export { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; -export { updatePairedDeviceMetadata } from "../infra/device-pairing.js"; -export { updatePairedNodeMetadata } from "../infra/node-pairing.js"; export { deliverOutboundPayloads } from "../infra/outbound/deliver.js"; export { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; export { resolveOutboundTarget } from "../infra/outbound/targets.js"; diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 749a177d13f..8126df1d6c6 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -47,8 +47,6 @@ const loadOrCreateDeviceIdentityMock = vi.hoisted(() => privateKeyPem: "private", })), ); -const updatePairedDeviceMetadataMock = vi.hoisted(() => vi.fn()); -const updatePairedNodeMetadataMock = vi.hoisted(() => vi.fn()); const parseMessageWithAttachmentsMock = vi.hoisted(() => vi.fn()); const normalizeChannelIdMock = vi.hoisted(() => vi.fn((channel?: string | null) => channel ?? null), @@ -88,8 +86,6 @@ const runtimeMocks = vi.hoisted(() => ({ ), normalizeChannelId: normalizeChannelIdMock, normalizeMainKey: vi.fn((key?: string | null) => key?.trim() || "agent:main:main"), - updatePairedDeviceMetadata: updatePairedDeviceMetadataMock, - updatePairedNodeMetadata: updatePairedNodeMetadataMock, normalizeRpcAttachmentsToChatAttachments: vi.fn((attachments?: unknown[]) => attachments ?? []), parseMessageWithAttachments: parseMessageWithAttachmentsMock, registerApnsRegistration: registerApnsRegistrationMock, @@ -149,8 +145,6 @@ const updateSessionStoreMock = runtimeMocks.updateSessionStore; const loadSessionEntryMock = runtimeMocks.loadSessionEntry; const registerApnsRegistrationVi = runtimeMocks.registerApnsRegistration; const normalizeChannelIdVi = runtimeMocks.normalizeChannelId; -const updatePairedDeviceMetadataVi = runtimeMocks.updatePairedDeviceMetadata; -const updatePairedNodeMetadataVi = runtimeMocks.updatePairedNodeMetadata; function buildCtx(): NodeEventContext { return { @@ -182,8 +176,6 @@ describe("node exec events", () => { registerApnsRegistrationVi.mockClear(); loadOrCreateDeviceIdentityMock.mockClear(); normalizeChannelIdVi.mockClear(); - updatePairedDeviceMetadataVi.mockClear(); - updatePairedNodeMetadataVi.mockClear(); normalizeChannelIdVi.mockImplementation((channel?: string | null) => channel ?? null); }); @@ -448,57 +440,6 @@ describe("node exec events", () => { expect(registerApnsRegistrationVi).not.toHaveBeenCalled(); }); - - it("stores durable node last-seen metadata from alive beacons", async () => { - const ctx = buildCtx(); - await handleNodeEvent(ctx, "node-alive", { - event: "node.presence.alive", - payloadJSON: JSON.stringify({ - displayName: "Sim iPhone", - version: "2026.4.8", - platform: "iOS 26.0", - deviceFamily: "iPhone", - modelIdentifier: "iPhone17,1", - trigger: "bg_app_refresh", - pushTransport: "direct", - sentAtMs: 123, - }), - }); - - expect(updatePairedNodeMetadataVi).toHaveBeenCalledTimes(1); - expect(updatePairedDeviceMetadataVi).toHaveBeenCalledTimes(1); - expect(updatePairedNodeMetadataVi).toHaveBeenCalledWith("node-alive", { - lastSeenReason: "bg_app_refresh", - lastSeenAtMs: expect.any(Number), - }); - expect(updatePairedDeviceMetadataVi).toHaveBeenCalledWith("node-alive", { - lastSeenReason: "bg_app_refresh", - lastSeenAtMs: expect.any(Number), - }); - }); - - it("updates compatible node pairing aliases for alive beacons", async () => { - const ctx = buildCtx(); - await handleNodeEvent( - ctx, - "node-alive", - { - event: "node.presence.alive", - payloadJSON: JSON.stringify({ trigger: "silent_push" }), - }, - { nodePairingIds: ["node-alive", "legacy-instance"] }, - ); - - expect(updatePairedNodeMetadataVi).toHaveBeenCalledTimes(2); - expect(updatePairedNodeMetadataVi).toHaveBeenNthCalledWith(1, "node-alive", { - lastSeenReason: "silent_push", - lastSeenAtMs: expect.any(Number), - }); - expect(updatePairedNodeMetadataVi).toHaveBeenNthCalledWith(2, "legacy-instance", { - lastSeenReason: "silent_push", - lastSeenAtMs: expect.any(Number), - }); - }); }); describe("voice transcript events", () => { diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 62b5f55e548..8c52f9de4f2 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -20,8 +20,6 @@ import { loadSessionEntry, migrateAndPruneGatewaySessionStoreKey, normalizeChannelId, - updatePairedDeviceMetadata, - updatePairedNodeMetadata, normalizeMainKey, normalizeRpcAttachmentsToChatAttachments, parseMessageWithAttachments, @@ -265,12 +263,7 @@ async function sendReceiptAck(params: { }); } -export const handleNodeEvent = async ( - ctx: NodeEventContext, - nodeId: string, - evt: NodeEvent, - opts?: { nodePairingIds?: readonly string[] }, -) => { +export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt: NodeEvent) => { switch (evt.event) { case "voice.transcript": { const obj = parsePayloadObject(evt.payloadJSON); @@ -685,35 +678,6 @@ export const handleNodeEvent = async ( } return; } - case "node.presence.alive": { - const obj = parsePayloadObject(evt.payloadJSON); - if (!obj) { - return; - } - const trigger = normalizeOptionalString(obj.trigger) ?? "background"; - const receivedAtMs = Date.now(); - const nodePairingIds = new Set( - opts?.nodePairingIds?.length ? opts.nodePairingIds : [nodeId], - ); - try { - await Promise.all([ - ...[...nodePairingIds].map( - async (pairedNodeId) => - await updatePairedNodeMetadata(pairedNodeId, { - lastSeenAtMs: receivedAtMs, - lastSeenReason: trigger, - }), - ), - updatePairedDeviceMetadata(nodeId, { - lastSeenAtMs: receivedAtMs, - lastSeenReason: trigger, - }), - ]); - } catch (err) { - ctx.logGateway.warn(`node presence alive failed node=${nodeId}: ${formatForLog(err)}`); - } - return; - } default: return; } diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 6d789ccdfe4..086aaaa50fa 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1260,25 +1260,10 @@ export function attachGatewayWsMessageHandler(params: { for (const nodeId of nodeIdsForPairing) { void updatePairedNodeMetadata(nodeId, { lastConnectedAtMs: nodeSession.connectedAtMs, - lastSeenAtMs: nodeSession.connectedAtMs, - lastSeenReason: "connect", }).catch((err) => logGateway.warn(`failed to record last connect for ${nodeId}: ${formatForLog(err)}`), ); } - if (device?.id) { - void updatePairedDeviceMetadata(device.id, { - clientId: nodeSession.clientId, - clientMode: nodeSession.clientMode, - remoteIp: nodeSession.remoteIp, - lastSeenAtMs: nodeSession.connectedAtMs, - lastSeenReason: "connect", - }).catch((err) => - logGateway.warn( - `failed to record device last seen for ${device.id}: ${formatForLog(err)}`, - ), - ); - } recordRemoteNodeInfo({ nodeId: nodeSession.nodeId, displayName: nodeSession.displayName, diff --git a/src/infra/device-pairing.ts b/src/infra/device-pairing.ts index f6c2143b4ab..28d40bca9fb 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -78,8 +78,6 @@ export type PairedDevice = { tokens?: Record; createdAtMs: number; approvedAtMs: number; - lastSeenAtMs?: number; - lastSeenReason?: string; }; export type DevicePairingList = { @@ -745,8 +743,6 @@ export async function updatePairedDeviceMetadata( role: patch.role ?? existing.role, roles, scopes, - lastSeenAtMs: patch.lastSeenAtMs ?? existing.lastSeenAtMs, - lastSeenReason: patch.lastSeenReason ?? existing.lastSeenReason, }; await persistState(state, baseDir); }); diff --git a/src/infra/node-pairing.test.ts b/src/infra/node-pairing.test.ts index bc6bc831613..70b81aed614 100644 --- a/src/infra/node-pairing.test.ts +++ b/src/infra/node-pairing.test.ts @@ -5,7 +5,6 @@ import { getPairedNode, listNodePairing, requestNodePairing, - updatePairedNodeMetadata, verifyNodeToken, } from "./node-pairing.js"; @@ -258,26 +257,4 @@ describe("node pairing tokens", () => { }); }); }); - - test("persists last-seen metadata updates for paired nodes", async () => { - await withNodePairingDir(async (baseDir) => { - await setupPairedNode(baseDir); - await updatePairedNodeMetadata( - "node-1", - { - lastSeenAtMs: 123_456, - lastSeenReason: "bg_app_refresh", - }, - baseDir, - ); - - await expect(getPairedNode("node-1", baseDir)).resolves.toEqual( - expect.objectContaining({ - nodeId: "node-1", - lastSeenAtMs: 123_456, - lastSeenReason: "bg_app_refresh", - }), - ); - }); - }); }); diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index 890339e058c..0a371615178 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -50,8 +50,6 @@ export type NodePairingPairedNode = NodeApprovedSurface & { createdAtMs: number; approvedAtMs: number; lastConnectedAtMs?: number; - lastSeenAtMs?: number; - lastSeenReason?: string; }; export type NodePairingList = { @@ -331,8 +329,6 @@ export async function updatePairedNodeMetadata( bins: patch.bins ?? existing.bins, permissions: patch.permissions ?? existing.permissions, lastConnectedAtMs: patch.lastConnectedAtMs ?? existing.lastConnectedAtMs, - lastSeenAtMs: patch.lastSeenAtMs ?? existing.lastSeenAtMs, - lastSeenReason: patch.lastSeenReason ?? existing.lastSeenReason, }; state.pairedByNodeId[normalized] = next; diff --git a/src/shared/node-list-types.ts b/src/shared/node-list-types.ts index d73a1495d93..21216b0fb10 100644 --- a/src/shared/node-list-types.ts +++ b/src/shared/node-list-types.ts @@ -18,8 +18,6 @@ export type NodeListNode = { connected?: boolean; connectedAtMs?: number; approvedAtMs?: number; - lastSeenAtMs?: number; - lastSeenReason?: string; }; export type PendingRequest = { @@ -49,8 +47,6 @@ export type PairedNode = { createdAtMs?: number; approvedAtMs?: number; lastConnectedAtMs?: number; - lastSeenAtMs?: number; - lastSeenReason?: string; }; export type PairingList = {