revert: undo background alive review findings fix

This commit is contained in:
Nimrod Gutman
2026-04-08 14:01:53 +03:00
parent 0e6e974117
commit f3c304917a
14 changed files with 22 additions and 667 deletions

View File

@@ -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?,

View File

@@ -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))
}
}

View File

@@ -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)"])
}
}

View File

@@ -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,
}),

View File

@@ -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),
};

View File

@@ -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);
});
},

View File

@@ -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";

View File

@@ -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", () => {

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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",
}),
);
});
});
});

View File

@@ -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;

View File

@@ -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 = {