diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 1935976e6c6..a6f96acc522 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -18,6 +18,22 @@ 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() @@ -607,6 +623,9 @@ 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 { @@ -3097,7 +3116,9 @@ extension NodeAppModel { return handled } - let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + let result = await self.performBackgroundAliveBeaconIfNeeded( + wakeId: wakeId, + trigger: pushKind) let outcomeMessage = "Silent push outcome wakeId=\(wakeId) " + "applied=\(result.applied) " @@ -3115,7 +3136,9 @@ extension NodeAppModel { + "backgrounded=\(self.isBackgrounded) " + "autoReconnect=\(self.gatewayAutoReconnectEnabled)" self.pushWakeLogger.info("\(receivedMessage, privacy: .public)") - let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + let result = await self.performBackgroundAliveBeaconIfNeeded( + wakeId: wakeId, + trigger: trigger) let outcomeMessage = "Background refresh wake outcome wakeId=\(wakeId) " + "applied=\(result.applied) " @@ -3151,7 +3174,9 @@ extension NodeAppModel { + "backgrounded=\(self.isBackgrounded) " + "autoReconnect=\(self.gatewayAutoReconnectEnabled)" self.locationWakeLogger.info("\(beginMessage, privacy: .public)") - let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + let result = await self.performBackgroundAliveBeaconIfNeeded( + wakeId: wakeId, + trigger: "significant_location") let triggerMessage = "Location wake trigger wakeId=\(wakeId) " + "applied=\(result.applied) " @@ -3740,10 +3765,79 @@ extension NodeAppModel { return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250) } - private func reconnectGatewaySessionsForSilentPushIfNeeded( - wakeId: String + 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 ) 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( @@ -3765,18 +3859,48 @@ extension NodeAppModel { return makeResult(false, "no_active_gateway_config") } - 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") + 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") } } @@ -4126,6 +4250,15 @@ 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 { @@ -4248,6 +4381,30 @@ 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 new file mode 100644 index 00000000000..e0b9724e5de --- /dev/null +++ b/apps/ios/Tests/NodeBackgroundAliveBeaconTests.swift @@ -0,0 +1,56 @@ +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 new file mode 100644 index 00000000000..abcdc25059d --- /dev/null +++ b/apps/ios/Tests/NodeBackgroundAliveE2ETests.swift @@ -0,0 +1,129 @@ +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 2faf60e793c..7b329724c42 100644 --- a/src/gateway/node-catalog.test.ts +++ b/src/gateway/node-catalog.test.ts @@ -94,6 +94,8 @@ describe("gateway/node-catalog", () => { commands: ["system.run"], createdAtMs: 1, approvedAtMs: 100, + lastSeenAtMs: 111, + lastSeenReason: "bg_app_refresh", }, ], connectedNodes: [ @@ -135,6 +137,8 @@ describe("gateway/node-catalog", () => { pathEnv: "/usr/bin:/bin", approvedAtMs: 100, connectedAtMs, + lastSeenAtMs: connectedAtMs, + lastSeenReason: "connect", paired: true, connected: true, }), @@ -173,6 +177,8 @@ describe("gateway/node-catalog", () => { commands: ["system.run"], createdAtMs: 1, approvedAtMs: 123, + lastSeenAtMs: 456, + lastSeenReason: "silent_push", }, ], connectedNodes: [], @@ -193,6 +199,102 @@ 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 8ceda8071de..c7900513ed6 100644 --- a/src/gateway/node-catalog.ts +++ b/src/gateway/node-catalog.ts @@ -12,6 +12,8 @@ export type KnownNodeDevicePairingSource = { clientMode?: string; remoteIp?: string; approvedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; }; export type KnownNodeApprovedSource = { @@ -28,6 +30,8 @@ export type KnownNodeApprovedSource = { commands: string[]; permissions?: Record; approvedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; }; export type KnownNodeEntry = { @@ -67,6 +71,8 @@ function buildDevicePairingSource(entry: PairedDevice): KnownNodeDevicePairingSo clientMode: entry.clientMode, remoteIp: entry.remoteIp, approvedAtMs: entry.approvedAtMs, + lastSeenAtMs: entry.lastSeenAtMs, + lastSeenReason: entry.lastSeenReason, }; } @@ -85,6 +91,41 @@ 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, }; } @@ -95,6 +136,7 @@ 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, @@ -115,6 +157,8 @@ 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 342e98d6d87..84220587003 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -1131,10 +1131,20 @@ export const nodeHandlers: GatewayRequestHandlers = { loadGatewayModelCatalog: context.loadGatewayModelCatalog, logGateway: { warn: context.logGateway.warn }, }; - await handleNodeEvent(nodeContext, nodeId, { - event: p.event, - payloadJSON, - }); + 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] }, + ); respond(true, { ok: true }, undefined); }); }, diff --git a/src/gateway/server-node-events.runtime.ts b/src/gateway/server-node-events.runtime.ts index 8ba881dace9..ab05662c4d4 100644 --- a/src/gateway/server-node-events.runtime.ts +++ b/src/gateway/server-node-events.runtime.ts @@ -7,6 +7,8 @@ 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 8126df1d6c6..749a177d13f 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -47,6 +47,8 @@ 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), @@ -86,6 +88,8 @@ 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, @@ -145,6 +149,8 @@ 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 { @@ -176,6 +182,8 @@ describe("node exec events", () => { registerApnsRegistrationVi.mockClear(); loadOrCreateDeviceIdentityMock.mockClear(); normalizeChannelIdVi.mockClear(); + updatePairedDeviceMetadataVi.mockClear(); + updatePairedNodeMetadataVi.mockClear(); normalizeChannelIdVi.mockImplementation((channel?: string | null) => channel ?? null); }); @@ -440,6 +448,57 @@ 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 8c52f9de4f2..62b5f55e548 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -20,6 +20,8 @@ import { loadSessionEntry, migrateAndPruneGatewaySessionStoreKey, normalizeChannelId, + updatePairedDeviceMetadata, + updatePairedNodeMetadata, normalizeMainKey, normalizeRpcAttachmentsToChatAttachments, parseMessageWithAttachments, @@ -263,7 +265,12 @@ async function sendReceiptAck(params: { }); } -export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt: NodeEvent) => { +export const handleNodeEvent = async ( + ctx: NodeEventContext, + nodeId: string, + evt: NodeEvent, + opts?: { nodePairingIds?: readonly string[] }, +) => { switch (evt.event) { case "voice.transcript": { const obj = parsePayloadObject(evt.payloadJSON); @@ -678,6 +685,35 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt } 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 086aaaa50fa..6d789ccdfe4 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1260,10 +1260,25 @@ 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 28d40bca9fb..f6c2143b4ab 100644 --- a/src/infra/device-pairing.ts +++ b/src/infra/device-pairing.ts @@ -78,6 +78,8 @@ export type PairedDevice = { tokens?: Record; createdAtMs: number; approvedAtMs: number; + lastSeenAtMs?: number; + lastSeenReason?: string; }; export type DevicePairingList = { @@ -743,6 +745,8 @@ 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 70b81aed614..bc6bc831613 100644 --- a/src/infra/node-pairing.test.ts +++ b/src/infra/node-pairing.test.ts @@ -5,6 +5,7 @@ import { getPairedNode, listNodePairing, requestNodePairing, + updatePairedNodeMetadata, verifyNodeToken, } from "./node-pairing.js"; @@ -257,4 +258,26 @@ 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 0a371615178..890339e058c 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -50,6 +50,8 @@ export type NodePairingPairedNode = NodeApprovedSurface & { createdAtMs: number; approvedAtMs: number; lastConnectedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; }; export type NodePairingList = { @@ -329,6 +331,8 @@ 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 21216b0fb10..d73a1495d93 100644 --- a/src/shared/node-list-types.ts +++ b/src/shared/node-list-types.ts @@ -18,6 +18,8 @@ export type NodeListNode = { connected?: boolean; connectedAtMs?: number; approvedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; }; export type PendingRequest = { @@ -47,6 +49,8 @@ export type PairedNode = { createdAtMs?: number; approvedAtMs?: number; lastConnectedAtMs?: number; + lastSeenAtMs?: number; + lastSeenReason?: string; }; export type PairingList = {