mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
revert: undo background alive review findings fix
This commit is contained in:
@@ -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<T: Sendable>: @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?,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)"])
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
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<NodeListNode, "lastSeenAtMs" | "lastSeenReason"> {
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -1131,20 +1131,10 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
logGateway: { warn: context.logGateway.warn },
|
||||
};
|
||||
const nodePairingIds = new Set<string>([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);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string>(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -78,8 +78,6 @@ export type PairedDevice = {
|
||||
tokens?: Record<string, DeviceAuthToken>;
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user