mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Gateway/iOS: replay queued foreground actions safely after resume (#40281)
Merged via squash. - Local validation: `pnpm exec vitest run --config vitest.gateway.config.ts src/gateway/server-methods/nodes.invoke-wake.test.ts` - Local validation: `pnpm build` - mb-server validation: `pnpm exec vitest run --config vitest.gateway.config.ts src/gateway/server-methods/nodes.invoke-wake.test.ts` - mb-server validation: `pnpm build` - mb-server validation: `pnpm protocol:check`
This commit is contained in:
@@ -57,6 +57,7 @@ final class NodeAppModel {
|
|||||||
|
|
||||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||||
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
||||||
|
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
|
||||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||||
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
||||||
enum CameraHUDKind {
|
enum CameraHUDKind {
|
||||||
@@ -130,6 +131,7 @@ final class NodeAppModel {
|
|||||||
private var backgroundReconnectLeaseUntil: Date?
|
private var backgroundReconnectLeaseUntil: Date?
|
||||||
private var lastSignificantLocationWakeAt: Date?
|
private var lastSignificantLocationWakeAt: Date?
|
||||||
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
|
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
|
||||||
|
private var pendingForegroundActionDrainInFlight = false
|
||||||
|
|
||||||
private var gatewayConnected = false
|
private var gatewayConnected = false
|
||||||
private var operatorConnected = false
|
private var operatorConnected = false
|
||||||
@@ -329,6 +331,9 @@ final class NodeAppModel {
|
|||||||
}
|
}
|
||||||
await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
|
await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
|
||||||
}
|
}
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.resumePendingForegroundNodeActionsIfNeeded(trigger: "scene_active")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if phase == .active, self.reconnectAfterBackgroundArmed {
|
if phase == .active, self.reconnectAfterBackgroundArmed {
|
||||||
self.reconnectAfterBackgroundArmed = false
|
self.reconnectAfterBackgroundArmed = false
|
||||||
@@ -2098,6 +2103,22 @@ private extension NodeAppModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension NodeAppModel {
|
extension NodeAppModel {
|
||||||
|
private struct PendingForegroundNodeAction: Decodable {
|
||||||
|
var id: String
|
||||||
|
var command: String
|
||||||
|
var paramsJSON: String?
|
||||||
|
var enqueuedAtMs: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PendingForegroundNodeActionsResponse: Decodable {
|
||||||
|
var nodeId: String?
|
||||||
|
var actions: [PendingForegroundNodeAction]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PendingForegroundNodeActionsAckRequest: Encodable {
|
||||||
|
var ids: [String]
|
||||||
|
}
|
||||||
|
|
||||||
private func refreshShareRouteFromGateway() async {
|
private func refreshShareRouteFromGateway() async {
|
||||||
struct Params: Codable {
|
struct Params: Codable {
|
||||||
var includeGlobal: Bool
|
var includeGlobal: Bool
|
||||||
@@ -2195,6 +2216,83 @@ extension NodeAppModel {
|
|||||||
func onNodeGatewayConnected() async {
|
func onNodeGatewayConnected() async {
|
||||||
await self.registerAPNsTokenIfNeeded()
|
await self.registerAPNsTokenIfNeeded()
|
||||||
await self.flushQueuedWatchRepliesIfConnected()
|
await self.flushQueuedWatchRepliesIfConnected()
|
||||||
|
await self.resumePendingForegroundNodeActionsIfNeeded(trigger: "node_connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resumePendingForegroundNodeActionsIfNeeded(trigger: String) async {
|
||||||
|
guard !self.isBackgrounded else { return }
|
||||||
|
guard await self.isGatewayConnected() else { return }
|
||||||
|
guard !self.pendingForegroundActionDrainInFlight else { return }
|
||||||
|
|
||||||
|
self.pendingForegroundActionDrainInFlight = true
|
||||||
|
defer { self.pendingForegroundActionDrainInFlight = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let payload = try await self.nodeGateway.request(
|
||||||
|
method: "node.pending.pull",
|
||||||
|
paramsJSON: "{}",
|
||||||
|
timeoutSeconds: 6)
|
||||||
|
let decoded = try JSONDecoder().decode(
|
||||||
|
PendingForegroundNodeActionsResponse.self,
|
||||||
|
from: payload)
|
||||||
|
guard !decoded.actions.isEmpty else { return }
|
||||||
|
self.pendingActionLogger.info(
|
||||||
|
"Pending actions pulled trigger=\(trigger, privacy: .public) "
|
||||||
|
+ "count=\(decoded.actions.count, privacy: .public)")
|
||||||
|
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
|
||||||
|
} catch {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPendingForegroundNodeActions(
|
||||||
|
_ actions: [PendingForegroundNodeAction],
|
||||||
|
trigger: String) async
|
||||||
|
{
|
||||||
|
for action in actions {
|
||||||
|
guard !self.isBackgrounded else {
|
||||||
|
self.pendingActionLogger.info(
|
||||||
|
"Pending action replay paused trigger=\(trigger, privacy: .public): app backgrounded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let req = BridgeInvokeRequest(
|
||||||
|
id: action.id,
|
||||||
|
command: action.command,
|
||||||
|
paramsJSON: action.paramsJSON)
|
||||||
|
let result = await self.handleInvoke(req)
|
||||||
|
self.pendingActionLogger.info(
|
||||||
|
"Pending action replay trigger=\(trigger, privacy: .public) "
|
||||||
|
+ "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
|
||||||
|
+ "ok=\(result.ok, privacy: .public)")
|
||||||
|
guard result.ok else { return }
|
||||||
|
let acked = await self.ackPendingForegroundNodeAction(
|
||||||
|
id: action.id,
|
||||||
|
trigger: trigger,
|
||||||
|
command: action.command)
|
||||||
|
guard acked else { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ackPendingForegroundNodeAction(
|
||||||
|
id: String,
|
||||||
|
trigger: String,
|
||||||
|
command: String) async -> Bool
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
let payload = try JSONEncoder().encode(PendingForegroundNodeActionsAckRequest(ids: [id]))
|
||||||
|
let paramsJSON = String(decoding: payload, as: UTF8.self)
|
||||||
|
_ = try await self.nodeGateway.request(
|
||||||
|
method: "node.pending.ack",
|
||||||
|
paramsJSON: paramsJSON,
|
||||||
|
timeoutSeconds: 6)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
self.pendingActionLogger.error(
|
||||||
|
"Pending action ack failed trigger=\(trigger, privacy: .public) "
|
||||||
|
+ "id=\(id, privacy: .public) command=\(command, privacy: .public) "
|
||||||
|
+ "error=\(String(describing: error), privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
||||||
@@ -2843,6 +2941,19 @@ extension NodeAppModel {
|
|||||||
self.gatewayConnected = connected
|
self.gatewayConnected = connected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _test_applyPendingForegroundNodeActions(
|
||||||
|
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
|
||||||
|
{
|
||||||
|
let mapped = actions.map { action in
|
||||||
|
PendingForegroundNodeAction(
|
||||||
|
id: action.id,
|
||||||
|
command: action.command,
|
||||||
|
paramsJSON: action.paramsJSON,
|
||||||
|
enqueuedAtMs: nil)
|
||||||
|
}
|
||||||
|
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
|
||||||
|
}
|
||||||
|
|
||||||
static func _test_currentDeepLinkKey() -> String {
|
static func _test_currentDeepLinkKey() -> String {
|
||||||
self.expectedDeepLinkKey()
|
self.expectedDeepLinkKey()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,41 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
|||||||
#expect(payload?["result"] as? String == "2")
|
#expect(payload?["result"] as? String == "2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func pendingForegroundActionsReplayCanvasNavigate() async throws {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
|
||||||
|
let navData = try JSONEncoder().encode(navigateParams)
|
||||||
|
let navJSON = String(decoding: navData, as: UTF8.self)
|
||||||
|
|
||||||
|
await appModel._test_applyPendingForegroundNodeActions([
|
||||||
|
(
|
||||||
|
id: "pending-nav-1",
|
||||||
|
command: OpenClawCanvasCommand.navigate.rawValue,
|
||||||
|
paramsJSON: navJSON
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(appModel.screen.urlString == "http://example.com/")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func pendingForegroundActionsDoNotApplyWhileBackgrounded() async throws {
|
||||||
|
let appModel = NodeAppModel()
|
||||||
|
appModel.setScenePhase(.background)
|
||||||
|
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
|
||||||
|
let navData = try JSONEncoder().encode(navigateParams)
|
||||||
|
let navJSON = String(decoding: navData, as: UTF8.self)
|
||||||
|
|
||||||
|
await appModel._test_applyPendingForegroundNodeActions([
|
||||||
|
(
|
||||||
|
id: "pending-nav-bg",
|
||||||
|
command: OpenClawCanvasCommand.navigate.rawValue,
|
||||||
|
paramsJSON: navJSON
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(appModel.screen.urlString.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {
|
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {
|
||||||
let appModel = NodeAppModel()
|
let appModel = NodeAppModel()
|
||||||
|
|
||||||
|
|||||||
@@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable {
|
|||||||
|
|
||||||
public struct NodeListParams: Codable, Sendable {}
|
public struct NodeListParams: Codable, Sendable {}
|
||||||
|
|
||||||
|
public struct NodePendingAckParams: Codable, Sendable {
|
||||||
|
public let ids: [String]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
ids: [String])
|
||||||
|
{
|
||||||
|
self.ids = ids
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct NodeDescribeParams: Codable, Sendable {
|
public struct NodeDescribeParams: Codable, Sendable {
|
||||||
public let nodeid: String
|
public let nodeid: String
|
||||||
|
|
||||||
|
|||||||
@@ -836,6 +836,20 @@ public struct NodeRenameParams: Codable, Sendable {
|
|||||||
|
|
||||||
public struct NodeListParams: Codable, Sendable {}
|
public struct NodeListParams: Codable, Sendable {}
|
||||||
|
|
||||||
|
public struct NodePendingAckParams: Codable, Sendable {
|
||||||
|
public let ids: [String]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
ids: [String])
|
||||||
|
{
|
||||||
|
self.ids = ids
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct NodeDescribeParams: Codable, Sendable {
|
public struct NodeDescribeParams: Codable, Sendable {
|
||||||
public let nodeid: String
|
public let nodeid: String
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const NODE_ROLE_METHODS = new Set([
|
|||||||
"node.invoke.result",
|
"node.invoke.result",
|
||||||
"node.event",
|
"node.event",
|
||||||
"node.canvas.capability.refresh",
|
"node.canvas.capability.refresh",
|
||||||
|
"node.pending.pull",
|
||||||
|
"node.pending.ack",
|
||||||
"skills.bins",
|
"skills.bins",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ import {
|
|||||||
NodeInvokeResultParamsSchema,
|
NodeInvokeResultParamsSchema,
|
||||||
type NodeListParams,
|
type NodeListParams,
|
||||||
NodeListParamsSchema,
|
NodeListParamsSchema,
|
||||||
|
type NodePendingAckParams,
|
||||||
|
NodePendingAckParamsSchema,
|
||||||
type NodePairApproveParams,
|
type NodePairApproveParams,
|
||||||
NodePairApproveParamsSchema,
|
NodePairApproveParamsSchema,
|
||||||
type NodePairListParams,
|
type NodePairListParams,
|
||||||
@@ -285,6 +287,9 @@ export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>(
|
|||||||
);
|
);
|
||||||
export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(NodeRenameParamsSchema);
|
export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(NodeRenameParamsSchema);
|
||||||
export const validateNodeListParams = ajv.compile<NodeListParams>(NodeListParamsSchema);
|
export const validateNodeListParams = ajv.compile<NodeListParams>(NodeListParamsSchema);
|
||||||
|
export const validateNodePendingAckParams = ajv.compile<NodePendingAckParams>(
|
||||||
|
NodePendingAckParamsSchema,
|
||||||
|
);
|
||||||
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(NodeDescribeParamsSchema);
|
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(NodeDescribeParamsSchema);
|
||||||
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(NodeInvokeParamsSchema);
|
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(NodeInvokeParamsSchema);
|
||||||
export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams>(
|
export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams>(
|
||||||
@@ -465,6 +470,7 @@ export {
|
|||||||
NodePairRejectParamsSchema,
|
NodePairRejectParamsSchema,
|
||||||
NodePairVerifyParamsSchema,
|
NodePairVerifyParamsSchema,
|
||||||
NodeListParamsSchema,
|
NodeListParamsSchema,
|
||||||
|
NodePendingAckParamsSchema,
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
SessionsPreviewParamsSchema,
|
SessionsPreviewParamsSchema,
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ export const NodeRenameParamsSchema = Type.Object(
|
|||||||
|
|
||||||
export const NodeListParamsSchema = Type.Object({}, { additionalProperties: false });
|
export const NodeListParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||||
|
|
||||||
|
export const NodePendingAckParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
ids: Type.Array(NonEmptyString, { minItems: 1 }),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const NodeDescribeParamsSchema = Type.Object(
|
export const NodeDescribeParamsSchema = Type.Object(
|
||||||
{ nodeId: NonEmptyString },
|
{ nodeId: NonEmptyString },
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ import {
|
|||||||
NodeInvokeResultParamsSchema,
|
NodeInvokeResultParamsSchema,
|
||||||
NodeInvokeRequestEventSchema,
|
NodeInvokeRequestEventSchema,
|
||||||
NodeListParamsSchema,
|
NodeListParamsSchema,
|
||||||
|
NodePendingAckParamsSchema,
|
||||||
NodePairApproveParamsSchema,
|
NodePairApproveParamsSchema,
|
||||||
NodePairListParamsSchema,
|
NodePairListParamsSchema,
|
||||||
NodePairRejectParamsSchema,
|
NodePairRejectParamsSchema,
|
||||||
@@ -180,6 +181,7 @@ export const ProtocolSchemas = {
|
|||||||
NodePairVerifyParams: NodePairVerifyParamsSchema,
|
NodePairVerifyParams: NodePairVerifyParamsSchema,
|
||||||
NodeRenameParams: NodeRenameParamsSchema,
|
NodeRenameParams: NodeRenameParamsSchema,
|
||||||
NodeListParams: NodeListParamsSchema,
|
NodeListParams: NodeListParamsSchema,
|
||||||
|
NodePendingAckParams: NodePendingAckParamsSchema,
|
||||||
NodeDescribeParams: NodeDescribeParamsSchema,
|
NodeDescribeParams: NodeDescribeParamsSchema,
|
||||||
NodeInvokeParams: NodeInvokeParamsSchema,
|
NodeInvokeParams: NodeInvokeParamsSchema,
|
||||||
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
|
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type NodePairRejectParams = SchemaType<"NodePairRejectParams">;
|
|||||||
export type NodePairVerifyParams = SchemaType<"NodePairVerifyParams">;
|
export type NodePairVerifyParams = SchemaType<"NodePairVerifyParams">;
|
||||||
export type NodeRenameParams = SchemaType<"NodeRenameParams">;
|
export type NodeRenameParams = SchemaType<"NodeRenameParams">;
|
||||||
export type NodeListParams = SchemaType<"NodeListParams">;
|
export type NodeListParams = SchemaType<"NodeListParams">;
|
||||||
|
export type NodePendingAckParams = SchemaType<"NodePendingAckParams">;
|
||||||
export type NodeDescribeParams = SchemaType<"NodeDescribeParams">;
|
export type NodeDescribeParams = SchemaType<"NodeDescribeParams">;
|
||||||
export type NodeInvokeParams = SchemaType<"NodeInvokeParams">;
|
export type NodeInvokeParams = SchemaType<"NodeInvokeParams">;
|
||||||
export type NodeInvokeResultParams = SchemaType<"NodeInvokeResultParams">;
|
export type NodeInvokeResultParams = SchemaType<"NodeInvokeResultParams">;
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ const BASE_METHODS = [
|
|||||||
"node.list",
|
"node.list",
|
||||||
"node.describe",
|
"node.describe",
|
||||||
"node.invoke",
|
"node.invoke",
|
||||||
|
"node.pending.pull",
|
||||||
|
"node.pending.ack",
|
||||||
"node.invoke.result",
|
"node.invoke.result",
|
||||||
"node.event",
|
"node.event",
|
||||||
"node.canvas.capability.refresh",
|
"node.canvas.capability.refresh",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type RespondCall = [
|
|||||||
type TestNodeSession = {
|
type TestNodeSession = {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
commands: string[];
|
commands: string[];
|
||||||
|
platform?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WAKE_WAIT_TIMEOUT_MS = 3_001;
|
const WAKE_WAIT_TIMEOUT_MS = 3_001;
|
||||||
@@ -102,6 +103,54 @@ async function invokeNode(params: {
|
|||||||
return respond;
|
return respond;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pullPending(nodeId: string) {
|
||||||
|
const respond = vi.fn();
|
||||||
|
await nodeHandlers["node.pending.pull"]({
|
||||||
|
params: {},
|
||||||
|
respond: respond as never,
|
||||||
|
context: {} as never,
|
||||||
|
client: {
|
||||||
|
connect: {
|
||||||
|
role: "node",
|
||||||
|
client: {
|
||||||
|
id: nodeId,
|
||||||
|
mode: "node",
|
||||||
|
name: "ios-test",
|
||||||
|
platform: "iOS 26.4.0",
|
||||||
|
version: "test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
req: { type: "req", id: "req-node-pending", method: "node.pending.pull" },
|
||||||
|
isWebchatConnect: () => false,
|
||||||
|
});
|
||||||
|
return respond;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ackPending(nodeId: string, ids: string[]) {
|
||||||
|
const respond = vi.fn();
|
||||||
|
await nodeHandlers["node.pending.ack"]({
|
||||||
|
params: { ids },
|
||||||
|
respond: respond as never,
|
||||||
|
context: {} as never,
|
||||||
|
client: {
|
||||||
|
connect: {
|
||||||
|
role: "node",
|
||||||
|
client: {
|
||||||
|
id: nodeId,
|
||||||
|
mode: "node",
|
||||||
|
name: "ios-test",
|
||||||
|
platform: "iOS 26.4.0",
|
||||||
|
version: "test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
req: { type: "req", id: "req-node-pending-ack", method: "node.pending.ack" },
|
||||||
|
isWebchatConnect: () => false,
|
||||||
|
});
|
||||||
|
return respond;
|
||||||
|
}
|
||||||
|
|
||||||
function mockSuccessfulWakeConfig(nodeId: string) {
|
function mockSuccessfulWakeConfig(nodeId: string) {
|
||||||
mocks.loadApnsRegistration.mockResolvedValue({
|
mocks.loadApnsRegistration.mockResolvedValue({
|
||||||
nodeId,
|
nodeId,
|
||||||
@@ -229,4 +278,138 @@ describe("node.invoke APNs wake path", () => {
|
|||||||
expect(mocks.sendApnsBackgroundWake).toHaveBeenCalledTimes(2);
|
expect(mocks.sendApnsBackgroundWake).toHaveBeenCalledTimes(2);
|
||||||
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
|
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("queues iOS foreground-only command failures and keeps them until acked", async () => {
|
||||||
|
mocks.loadApnsRegistration.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const nodeRegistry = {
|
||||||
|
get: vi.fn(() => ({
|
||||||
|
nodeId: "ios-node-queued",
|
||||||
|
commands: ["canvas.navigate"],
|
||||||
|
platform: "iOS 26.4.0",
|
||||||
|
})),
|
||||||
|
invoke: vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
|
message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const respond = await invokeNode({
|
||||||
|
nodeRegistry,
|
||||||
|
requestParams: {
|
||||||
|
nodeId: "ios-node-queued",
|
||||||
|
command: "canvas.navigate",
|
||||||
|
params: { url: "http://example.com/" },
|
||||||
|
idempotencyKey: "idem-queued",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||||
|
expect(call?.[0]).toBe(false);
|
||||||
|
expect(call?.[2]?.code).toBe(ErrorCodes.UNAVAILABLE);
|
||||||
|
expect(call?.[2]?.message).toBe("node command queued until iOS returns to foreground");
|
||||||
|
expect(mocks.sendApnsBackgroundWake).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const pullRespond = await pullPending("ios-node-queued");
|
||||||
|
const pullCall = pullRespond.mock.calls[0] as RespondCall | undefined;
|
||||||
|
expect(pullCall?.[0]).toBe(true);
|
||||||
|
expect(pullCall?.[1]).toMatchObject({
|
||||||
|
nodeId: "ios-node-queued",
|
||||||
|
actions: [
|
||||||
|
expect.objectContaining({
|
||||||
|
command: "canvas.navigate",
|
||||||
|
paramsJSON: JSON.stringify({ url: "http://example.com/" }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const repeatedPullRespond = await pullPending("ios-node-queued");
|
||||||
|
const repeatedPullCall = repeatedPullRespond.mock.calls[0] as RespondCall | undefined;
|
||||||
|
expect(repeatedPullCall?.[0]).toBe(true);
|
||||||
|
expect(repeatedPullCall?.[1]).toMatchObject({
|
||||||
|
nodeId: "ios-node-queued",
|
||||||
|
actions: [
|
||||||
|
expect.objectContaining({
|
||||||
|
command: "canvas.navigate",
|
||||||
|
paramsJSON: JSON.stringify({ url: "http://example.com/" }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const queuedActionId = (pullCall?.[1] as { actions?: Array<{ id?: string }> } | undefined)
|
||||||
|
?.actions?.[0]?.id;
|
||||||
|
expect(queuedActionId).toBeTruthy();
|
||||||
|
|
||||||
|
const ackRespond = await ackPending("ios-node-queued", [queuedActionId!]);
|
||||||
|
const ackCall = ackRespond.mock.calls[0] as RespondCall | undefined;
|
||||||
|
expect(ackCall?.[0]).toBe(true);
|
||||||
|
expect(ackCall?.[1]).toMatchObject({
|
||||||
|
nodeId: "ios-node-queued",
|
||||||
|
ackedIds: [queuedActionId],
|
||||||
|
remainingCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyPullRespond = await pullPending("ios-node-queued");
|
||||||
|
const emptyPullCall = emptyPullRespond.mock.calls[0] as RespondCall | undefined;
|
||||||
|
expect(emptyPullCall?.[0]).toBe(true);
|
||||||
|
expect(emptyPullCall?.[1]).toMatchObject({
|
||||||
|
nodeId: "ios-node-queued",
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes queued foreground actions by idempotency key", async () => {
|
||||||
|
mocks.loadApnsRegistration.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const nodeRegistry = {
|
||||||
|
get: vi.fn(() => ({
|
||||||
|
nodeId: "ios-node-dedupe",
|
||||||
|
commands: ["canvas.navigate"],
|
||||||
|
platform: "iPadOS 26.4.0",
|
||||||
|
})),
|
||||||
|
invoke: vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
|
message: "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await invokeNode({
|
||||||
|
nodeRegistry,
|
||||||
|
requestParams: {
|
||||||
|
nodeId: "ios-node-dedupe",
|
||||||
|
command: "canvas.navigate",
|
||||||
|
params: { url: "http://example.com/first" },
|
||||||
|
idempotencyKey: "idem-dedupe",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await invokeNode({
|
||||||
|
nodeRegistry,
|
||||||
|
requestParams: {
|
||||||
|
nodeId: "ios-node-dedupe",
|
||||||
|
command: "canvas.navigate",
|
||||||
|
params: { url: "http://example.com/first" },
|
||||||
|
idempotencyKey: "idem-dedupe",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pullRespond = await pullPending("ios-node-dedupe");
|
||||||
|
const pullCall = pullRespond.mock.calls[0] as RespondCall | undefined;
|
||||||
|
expect(pullCall?.[0]).toBe(true);
|
||||||
|
expect(pullCall?.[1]).toMatchObject({
|
||||||
|
nodeId: "ios-node-dedupe",
|
||||||
|
actions: [
|
||||||
|
expect.objectContaining({
|
||||||
|
command: "canvas.navigate",
|
||||||
|
paramsJSON: JSON.stringify({ url: "http://example.com/first" }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const actions = (pullCall?.[1] as { actions?: unknown[] } | undefined)?.actions ?? [];
|
||||||
|
expect(actions).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { listDevicePairing } from "../../infra/device-pairing.js";
|
import { listDevicePairing } from "../../infra/device-pairing.js";
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
validateNodeEventParams,
|
validateNodeEventParams,
|
||||||
validateNodeInvokeParams,
|
validateNodeInvokeParams,
|
||||||
validateNodeListParams,
|
validateNodeListParams,
|
||||||
|
validateNodePendingAckParams,
|
||||||
validateNodePairApproveParams,
|
validateNodePairApproveParams,
|
||||||
validateNodePairListParams,
|
validateNodePairListParams,
|
||||||
validateNodePairRejectParams,
|
validateNodePairRejectParams,
|
||||||
@@ -50,6 +52,8 @@ const NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000;
|
|||||||
const NODE_WAKE_RECONNECT_POLL_MS = 150;
|
const NODE_WAKE_RECONNECT_POLL_MS = 150;
|
||||||
const NODE_WAKE_THROTTLE_MS = 15_000;
|
const NODE_WAKE_THROTTLE_MS = 15_000;
|
||||||
const NODE_WAKE_NUDGE_THROTTLE_MS = 10 * 60_000;
|
const NODE_WAKE_NUDGE_THROTTLE_MS = 10 * 60_000;
|
||||||
|
const NODE_PENDING_ACTION_TTL_MS = 10 * 60_000;
|
||||||
|
const NODE_PENDING_ACTION_MAX_PER_NODE = 64;
|
||||||
|
|
||||||
type NodeWakeState = {
|
type NodeWakeState = {
|
||||||
lastWakeAtMs: number;
|
lastWakeAtMs: number;
|
||||||
@@ -77,6 +81,17 @@ type NodeWakeNudgeAttempt = {
|
|||||||
apnsReason?: string;
|
apnsReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingNodeAction = {
|
||||||
|
id: string;
|
||||||
|
nodeId: string;
|
||||||
|
command: string;
|
||||||
|
paramsJSON?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
enqueuedAtMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingNodeActionsById = new Map<string, PendingNodeAction[]>();
|
||||||
|
|
||||||
function isNodeEntry(entry: { role?: string; roles?: string[] }) {
|
function isNodeEntry(entry: { role?: string; roles?: string[] }) {
|
||||||
if (entry.role === "node") {
|
if (entry.role === "node") {
|
||||||
return true;
|
return true;
|
||||||
@@ -91,6 +106,108 @@ async function delayMs(ms: number): Promise<void> {
|
|||||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isForegroundRestrictedIosCommand(command: string): boolean {
|
||||||
|
return (
|
||||||
|
command === "canvas.present" ||
|
||||||
|
command === "canvas.navigate" ||
|
||||||
|
command.startsWith("canvas.") ||
|
||||||
|
command.startsWith("camera.") ||
|
||||||
|
command.startsWith("screen.") ||
|
||||||
|
command.startsWith("talk.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldQueueAsPendingForegroundAction(params: {
|
||||||
|
platform?: string;
|
||||||
|
command: string;
|
||||||
|
error: unknown;
|
||||||
|
}): boolean {
|
||||||
|
const platform = (params.platform ?? "").trim().toLowerCase();
|
||||||
|
if (!platform.startsWith("ios") && !platform.startsWith("ipados")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isForegroundRestrictedIosCommand(params.command)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const error =
|
||||||
|
params.error && typeof params.error === "object"
|
||||||
|
? (params.error as { code?: unknown; message?: unknown })
|
||||||
|
: null;
|
||||||
|
const code = typeof error?.code === "string" ? error.code.trim().toUpperCase() : "";
|
||||||
|
const message = typeof error?.message === "string" ? error.message.trim().toUpperCase() : "";
|
||||||
|
return code === "NODE_BACKGROUND_UNAVAILABLE" || message.includes("BACKGROUND_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
function prunePendingNodeActions(nodeId: string, nowMs: number): PendingNodeAction[] {
|
||||||
|
const queue = pendingNodeActionsById.get(nodeId) ?? [];
|
||||||
|
const minTimestampMs = nowMs - NODE_PENDING_ACTION_TTL_MS;
|
||||||
|
const live = queue.filter((entry) => entry.enqueuedAtMs >= minTimestampMs);
|
||||||
|
if (live.length === 0) {
|
||||||
|
pendingNodeActionsById.delete(nodeId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
pendingNodeActionsById.set(nodeId, live);
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueuePendingNodeAction(params: {
|
||||||
|
nodeId: string;
|
||||||
|
command: string;
|
||||||
|
paramsJSON?: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}): PendingNodeAction {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const queue = prunePendingNodeActions(params.nodeId, nowMs);
|
||||||
|
const existing = queue.find((entry) => entry.idempotencyKey === params.idempotencyKey);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const entry: PendingNodeAction = {
|
||||||
|
id: randomUUID(),
|
||||||
|
nodeId: params.nodeId,
|
||||||
|
command: params.command,
|
||||||
|
paramsJSON: params.paramsJSON,
|
||||||
|
idempotencyKey: params.idempotencyKey,
|
||||||
|
enqueuedAtMs: nowMs,
|
||||||
|
};
|
||||||
|
queue.push(entry);
|
||||||
|
if (queue.length > NODE_PENDING_ACTION_MAX_PER_NODE) {
|
||||||
|
queue.splice(0, queue.length - NODE_PENDING_ACTION_MAX_PER_NODE);
|
||||||
|
}
|
||||||
|
pendingNodeActionsById.set(params.nodeId, queue);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listPendingNodeActions(nodeId: string): PendingNodeAction[] {
|
||||||
|
return prunePendingNodeActions(nodeId, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function ackPendingNodeActions(nodeId: string, ids: string[]): PendingNodeAction[] {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return listPendingNodeActions(nodeId);
|
||||||
|
}
|
||||||
|
const pending = prunePendingNodeActions(nodeId, Date.now());
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
const remaining = pending.filter((entry) => !idSet.has(entry.id));
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
pendingNodeActionsById.delete(nodeId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
pendingNodeActionsById.set(nodeId, remaining);
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPendingParamsJSON(params: unknown): string | undefined {
|
||||||
|
if (params === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(params);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function maybeWakeNodeWithApns(
|
async function maybeWakeNodeWithApns(
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
opts?: { force?: boolean },
|
opts?: { force?: boolean },
|
||||||
@@ -596,6 +713,66 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
"node.pending.pull": async ({ params, respond, client }) => {
|
||||||
|
if (!validateNodeListParams(params)) {
|
||||||
|
respondInvalidParams({
|
||||||
|
respond,
|
||||||
|
method: "node.pending.pull",
|
||||||
|
validator: validateNodeListParams,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id;
|
||||||
|
const trimmedNodeId = String(nodeId ?? "").trim();
|
||||||
|
if (!trimmedNodeId) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = listPendingNodeActions(trimmedNodeId);
|
||||||
|
respond(
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
nodeId: trimmedNodeId,
|
||||||
|
actions: pending.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
command: entry.command,
|
||||||
|
paramsJSON: entry.paramsJSON ?? null,
|
||||||
|
enqueuedAtMs: entry.enqueuedAtMs,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"node.pending.ack": async ({ params, respond, client }) => {
|
||||||
|
if (!validateNodePendingAckParams(params)) {
|
||||||
|
respondInvalidParams({
|
||||||
|
respond,
|
||||||
|
method: "node.pending.ack",
|
||||||
|
validator: validateNodePendingAckParams,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nodeId = client?.connect?.device?.id ?? client?.connect?.client?.id;
|
||||||
|
const trimmedNodeId = String(nodeId ?? "").trim();
|
||||||
|
if (!trimmedNodeId) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ackIds = Array.from(
|
||||||
|
new Set((params.ids ?? []).map((value) => String(value ?? "").trim()).filter(Boolean)),
|
||||||
|
);
|
||||||
|
const remaining = ackPendingNodeActions(trimmedNodeId, ackIds);
|
||||||
|
respond(
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
nodeId: trimmedNodeId,
|
||||||
|
ackedIds: ackIds,
|
||||||
|
remainingCount: remaining.length,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
"node.invoke": async ({ params, respond, context, client, req }) => {
|
"node.invoke": async ({ params, respond, context, client, req }) => {
|
||||||
if (!validateNodeInvokeParams(params)) {
|
if (!validateNodeInvokeParams(params)) {
|
||||||
respondInvalidParams({
|
respondInvalidParams({
|
||||||
@@ -759,7 +936,56 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
timeoutMs: p.timeoutMs,
|
timeoutMs: p.timeoutMs,
|
||||||
idempotencyKey: p.idempotencyKey,
|
idempotencyKey: p.idempotencyKey,
|
||||||
});
|
});
|
||||||
if (!respondUnavailableOnNodeInvokeError(respond, res)) {
|
if (!res.ok) {
|
||||||
|
if (
|
||||||
|
shouldQueueAsPendingForegroundAction({
|
||||||
|
platform: nodeSession.platform,
|
||||||
|
command,
|
||||||
|
error: res.error,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
const paramsJSON = toPendingParamsJSON(forwardedParams.params);
|
||||||
|
const queued = enqueuePendingNodeAction({
|
||||||
|
nodeId,
|
||||||
|
command,
|
||||||
|
paramsJSON,
|
||||||
|
idempotencyKey: p.idempotencyKey,
|
||||||
|
});
|
||||||
|
const wake = await maybeWakeNodeWithApns(nodeId);
|
||||||
|
context.logGateway.info(
|
||||||
|
`node pending queued node=${nodeId} req=${req.id} command=${command} ` +
|
||||||
|
`queuedId=${queued.id} wakePath=${wake.path} wakeAvailable=${wake.available}`,
|
||||||
|
);
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.UNAVAILABLE,
|
||||||
|
"node command queued until iOS returns to foreground",
|
||||||
|
{
|
||||||
|
retryable: true,
|
||||||
|
details: {
|
||||||
|
code: "QUEUED_UNTIL_FOREGROUND",
|
||||||
|
queuedActionId: queued.id,
|
||||||
|
nodeId,
|
||||||
|
command,
|
||||||
|
wake: {
|
||||||
|
path: wake.path,
|
||||||
|
available: wake.available,
|
||||||
|
throttled: wake.throttled,
|
||||||
|
apnsStatus: wake.apnsStatus,
|
||||||
|
apnsReason: wake.apnsReason,
|
||||||
|
},
|
||||||
|
nodeError: res.error ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!respondUnavailableOnNodeInvokeError(respond, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
|
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
|
||||||
|
|||||||
Reference in New Issue
Block a user