mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 12:21:36 +00:00
refactor(ios): simplify node and Watch lifecycle ownership (#114366)
* refactor(ios): centralize node lifecycle owners * fix(ios): sync native localization source inventory * test(ios): prove gateway and notification lifecycles in simulator * fix(ios): preserve voice upgrade and correlate watch snapshots
This commit is contained in:
committed by
GitHub
parent
0cc0a100ee
commit
079ac9390d
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
@@ -3233,6 +3233,50 @@ jobs:
|
||||
- name: Build iOS app
|
||||
run: pnpm ios:build
|
||||
|
||||
# App compilation and screenshots do not execute approval or notification
|
||||
# lifecycles. Exercise both owners on the already provisioned simulator.
|
||||
- name: Run focused iOS lifecycle simulator tests
|
||||
id: ios_lifecycle_tests
|
||||
if: env.HISTORICAL_TARGET != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
simulator_id="$(
|
||||
xcrun simctl list devices available --json | node --input-type=module -e '
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const runtimes = JSON.parse(Buffer.concat(chunks).toString("utf8")).devices;
|
||||
const simulator = Object.values(runtimes)
|
||||
.flat()
|
||||
.find((device) => device.isAvailable && device.name.startsWith("iPhone"));
|
||||
if (!simulator) {
|
||||
console.error("No available iPhone simulator for iOS lifecycle tests");
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(simulator.udid);
|
||||
'
|
||||
)"
|
||||
result_bundle="apps/ios/build/LifecycleTestResults/OpenClawLifecycleTests.xcresult"
|
||||
mkdir -p "apps/ios/build/LifecycleTestResults"
|
||||
xcodebuild \
|
||||
-project apps/ios/OpenClaw.xcodeproj \
|
||||
-scheme OpenClaw \
|
||||
-configuration Debug \
|
||||
-destination "platform=iOS Simulator,id=${simulator_id}" \
|
||||
-resultBundlePath "$result_bundle" \
|
||||
-parallel-testing-enabled NO \
|
||||
-only-testing:OpenClawTests/NodeAppModelInvokeTests \
|
||||
-only-testing:OpenClawTests/NotificationServingPreferenceTests \
|
||||
test
|
||||
|
||||
- name: Upload iOS lifecycle simulator evidence
|
||||
if: ${{ always() && steps.ios_lifecycle_tests.outcome != '' && steps.ios_lifecycle_tests.outcome != 'skipped' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ios-lifecycle-tests-${{ needs.preflight.outputs.checkout_revision }}
|
||||
path: apps/ios/build/LifecycleTestResults/*.xcresult
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Capture iOS release screenshots
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && env.HISTORICAL_TARGET != 'true' }}
|
||||
run: pnpm ios:screenshots
|
||||
|
||||
@@ -25795,7 +25795,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4307,
|
||||
"line": 4233,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Approval needed",
|
||||
"surface": "apple",
|
||||
@@ -25803,7 +25803,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4308,
|
||||
"line": 4234,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Action required",
|
||||
"surface": "apple",
|
||||
@@ -25811,7 +25811,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 5023,
|
||||
"line": 4949,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connecting...",
|
||||
"surface": "apple",
|
||||
@@ -25819,7 +25819,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 5024,
|
||||
"line": 4950,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Reconnecting...",
|
||||
"surface": "apple",
|
||||
@@ -25827,7 +25827,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 5379,
|
||||
"line": 5305,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connected",
|
||||
"surface": "apple",
|
||||
@@ -25835,7 +25835,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 5379,
|
||||
"line": 5305,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Offline",
|
||||
"surface": "apple",
|
||||
@@ -25843,7 +25843,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 6549,
|
||||
"line": 6458,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "No chat messages yet",
|
||||
"surface": "apple",
|
||||
@@ -25851,7 +25851,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 6748,
|
||||
"line": 6520,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connecting…",
|
||||
"surface": "apple",
|
||||
@@ -25859,7 +25859,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 6748,
|
||||
"line": 6520,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Reconnecting…",
|
||||
"surface": "apple",
|
||||
@@ -25867,7 +25867,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 9157,
|
||||
"line": 8867,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Approval",
|
||||
"surface": "apple",
|
||||
@@ -25875,7 +25875,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 9157,
|
||||
"line": 8867,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "This approval was already",
|
||||
"surface": "apple",
|
||||
@@ -25883,7 +25883,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 9163,
|
||||
"line": 8873,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "This approval was already set to Always Allow.",
|
||||
"surface": "apple",
|
||||
@@ -25891,7 +25891,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 9164,
|
||||
"line": 8874,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Approval set to Always Allow.",
|
||||
"surface": "apple",
|
||||
@@ -25899,7 +25899,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 10501,
|
||||
"line": 10209,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "\\(urlText.prefix(500))…",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import CoreLocation
|
||||
import CryptoKit
|
||||
import Observation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
@@ -14,11 +13,6 @@ private let clientDatabaseLogger = Logger(
|
||||
subsystem: "ai.openclawfoundation.app",
|
||||
category: "ClientDatabases")
|
||||
|
||||
/// Wrap errors without pulling non-Sendable types into async notification paths.
|
||||
private struct NotificationCallError: Error {
|
||||
let message: String
|
||||
}
|
||||
|
||||
private struct GatewayRelayIdentityResponse: Decodable {
|
||||
let deviceId: String
|
||||
let publicKey: String
|
||||
@@ -30,27 +24,6 @@ private struct WatchChatPreview {
|
||||
var statusText: String?
|
||||
}
|
||||
|
||||
private struct WatchChatMetadataEnvelope: Decodable {
|
||||
struct Metadata: Decodable {
|
||||
var id: String?
|
||||
}
|
||||
|
||||
var metadata: Metadata?
|
||||
var messageToolMirror: [String: String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case metadata = "__openclaw"
|
||||
case messageToolMirror = "openclawMessageToolMirror"
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchChatMessageEntry {
|
||||
var message: OpenClawChatMessage
|
||||
var text: String
|
||||
var serverId: String?
|
||||
var isMessageToolMirror: Bool
|
||||
}
|
||||
|
||||
private struct ExecApprovalGatewayEventPayload: Decodable {
|
||||
var id: String
|
||||
}
|
||||
@@ -60,33 +33,6 @@ private struct NodeEventRequestPayload: Encodable {
|
||||
var 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()
|
||||
private var continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>?
|
||||
private var resumed = false
|
||||
|
||||
func setContinuation(_ continuation: CheckedContinuation<Result<T, NotificationCallError>, Never>) {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
self.continuation = continuation
|
||||
}
|
||||
|
||||
func resume(_ response: Result<T, NotificationCallError>) {
|
||||
let cont: CheckedContinuation<Result<T, NotificationCallError>, Never>?
|
||||
self.lock.lock()
|
||||
if self.resumed {
|
||||
self.lock.unlock()
|
||||
return
|
||||
}
|
||||
self.resumed = true
|
||||
cont = self.continuation
|
||||
self.continuation = nil
|
||||
self.lock.unlock()
|
||||
cont?.resume(returning: response)
|
||||
}
|
||||
}
|
||||
|
||||
private enum IOSDeepLinkAgentPolicy {
|
||||
static let maxMessageChars = 20000
|
||||
static let maxUnkeyedConfirmChars = 240
|
||||
@@ -97,11 +43,15 @@ private enum TalkCapturePreparationOwner {
|
||||
case chatDictation(epoch: UInt64)
|
||||
}
|
||||
|
||||
private enum GatewayConnectionWaitOwner {
|
||||
case node
|
||||
case `operator`
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
// swiftlint:disable type_body_length file_length
|
||||
final class NodeAppModel {
|
||||
private nonisolated static let watchChatPreviewItemLimit = 5
|
||||
private nonisolated static let watchMessageMaxImmediateRetryAttempts = 3
|
||||
|
||||
struct AgentDeepLinkPrompt: Identifiable, Equatable {
|
||||
@@ -1436,9 +1386,16 @@ final class NodeAppModel {
|
||||
}
|
||||
self.talkMode.setEnabled(enabled)
|
||||
if enabled {
|
||||
// Preserve a known missing scope before an offline config reload can
|
||||
// overwrite it; the operator reconnect owns the approval handshake.
|
||||
self.requestTalkPermissionUpgradeIfNeeded()
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.talkMode.reloadConfig()
|
||||
guard !self.forceOperatorTalkPermissionUpgradeRequest else { return }
|
||||
await self.talkMode.reloadConfig(shouldApply: { [weak self] in
|
||||
guard let self else { return false }
|
||||
return self.talkMode.isEnabled && !self.forceOperatorTalkPermissionUpgradeRequest
|
||||
})
|
||||
self.requestTalkPermissionUpgradeIfNeeded()
|
||||
}
|
||||
}
|
||||
@@ -2692,7 +2649,7 @@ final class NodeAppModel {
|
||||
error: OpenClawNodeError(code: .unavailable, message: "NOT_AUTHORIZED: notifications"))
|
||||
}
|
||||
|
||||
let addResult = await runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
|
||||
let addResult = await NotificationOperationRunner.run(timeoutSeconds: 2.0) { [notificationCenter] in
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
@@ -2749,7 +2706,7 @@ final class NodeAppModel {
|
||||
|
||||
let messageId = UUID().uuidString
|
||||
if notificationsAllowed {
|
||||
let addResult = await runNotificationCall(timeoutSeconds: 2.0) { [notificationCenter] in
|
||||
let addResult = await NotificationOperationRunner.run(timeoutSeconds: 2.0) { [notificationCenter] in
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "OpenClaw"
|
||||
content.body = text
|
||||
@@ -2782,7 +2739,7 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
let result = await runNotificationCall(timeoutSeconds: 1.5) { [notificationCenter] in
|
||||
let result = await NotificationOperationRunner.run(timeoutSeconds: 1.5) { [notificationCenter] in
|
||||
await notificationCenter.authorizationStatus()
|
||||
}
|
||||
switch result {
|
||||
@@ -2836,37 +2793,6 @@ final class NodeAppModel {
|
||||
UserDefaults.standard.removeObject(forKey: Self.execApprovalNotificationGuidanceSuppressedKey)
|
||||
}
|
||||
|
||||
private func runNotificationCall<T: Sendable>(
|
||||
timeoutSeconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> T) async -> Result<T, NotificationCallError>
|
||||
{
|
||||
let latch = NotificationInvokeLatch<T>()
|
||||
var opTask: Task<Void, Never>?
|
||||
var timeoutTask: Task<Void, Never>?
|
||||
defer {
|
||||
opTask?.cancel()
|
||||
timeoutTask?.cancel()
|
||||
}
|
||||
let clamped = max(0.0, timeoutSeconds)
|
||||
return await withCheckedContinuation { (cont: CheckedContinuation<Result<T, NotificationCallError>, Never>) in
|
||||
latch.setContinuation(cont)
|
||||
opTask = Task { @MainActor in
|
||||
do {
|
||||
let value = try await operation()
|
||||
latch.resume(.success(value))
|
||||
} catch {
|
||||
latch.resume(.failure(NotificationCallError(message: error.localizedDescription)))
|
||||
}
|
||||
}
|
||||
timeoutTask = Task.detached {
|
||||
if clamped > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000))
|
||||
}
|
||||
latch.resume(.failure(NotificationCallError(message: "notification request timed out")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDeviceInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
switch req.command {
|
||||
case OpenClawDeviceCommand.status.rawValue:
|
||||
@@ -5426,17 +5352,21 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
func enterAppleReviewDemoMode() {
|
||||
/// Both local transports must retire the same real gateway owners before a
|
||||
/// fixture publishes connected state; stale loops must never outlive the switch.
|
||||
private func prepareLocalGatewayFixture(screenshot: Bool) {
|
||||
self.invalidateGatewayConnectAttempts()
|
||||
self.isAppleReviewDemoModeEnabled = true
|
||||
self.isScreenshotFixtureModeEnabled = false
|
||||
self.isAppleReviewDemoModeEnabled = !screenshot
|
||||
self.isScreenshotFixtureModeEnabled = screenshot
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.nodeGatewayProblem = nil
|
||||
self.operatorGatewayProblem = nil
|
||||
self.credentialHandoffFailureGeneration = nil
|
||||
if !screenshot {
|
||||
self.credentialHandoffFailureGeneration = nil
|
||||
}
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
@@ -5444,7 +5374,8 @@ extension NodeAppModel {
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
self.gatewayHealthMonitor.stop()
|
||||
LiveActivityManager.shared.endActivity(reason: "apple_review_demo")
|
||||
LiveActivityManager.shared.endActivity(
|
||||
reason: screenshot ? "screenshot_fixture" : "apple_review_demo")
|
||||
|
||||
Task {
|
||||
await self.operatorGateway.disconnect()
|
||||
@@ -5453,6 +5384,20 @@ extension NodeAppModel {
|
||||
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.nodeStatusText = "Connected"
|
||||
}
|
||||
|
||||
private func configureLocalGatewayFixtureSession(agents: [AgentSummary]) {
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.gatewaySessionScope = "per-sender"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
self.gatewayAgents = agents
|
||||
self.focusedChatSessionKey = nil
|
||||
self.synchronizeTalkSessionKey()
|
||||
}
|
||||
|
||||
func enterAppleReviewDemoMode() {
|
||||
self.prepareLocalGatewayFixture(screenshot: false)
|
||||
self.gatewayServerName = AppleReviewDemoMode.gatewayName
|
||||
self.gatewayRemoteAddress = AppleReviewDemoMode.gatewayAddress
|
||||
self.connectedGatewayID = AppleReviewDemoMode.gatewayID
|
||||
@@ -5464,42 +5409,12 @@ extension NodeAppModel {
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.talkMode.setEnabled(false)
|
||||
self.talkMode.statusText = "Demo mode only"
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.gatewaySessionScope = "per-sender"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
self.gatewayAgents = AppleReviewDemoMode.agents
|
||||
self.focusedChatSessionKey = nil
|
||||
self.synchronizeTalkSessionKey()
|
||||
self.configureLocalGatewayFixtureSession(agents: AppleReviewDemoMode.agents)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
|
||||
func enterScreenshotFixtureMode() {
|
||||
self.invalidateGatewayConnectAttempts()
|
||||
self.isAppleReviewDemoModeEnabled = false
|
||||
self.isScreenshotFixtureModeEnabled = true
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.lastGatewayProblem = nil
|
||||
self.nodeGatewayProblem = nil
|
||||
self.operatorGatewayProblem = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
self.gatewayHealthMonitor.stop()
|
||||
LiveActivityManager.shared.endActivity(reason: "screenshot_fixture")
|
||||
|
||||
Task {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
}
|
||||
|
||||
self.gatewayStatusText = "Connected"
|
||||
self.nodeStatusText = "Connected"
|
||||
self.prepareLocalGatewayFixture(screenshot: true)
|
||||
self.gatewayServerName = ScreenshotFixtureMode.gatewayName
|
||||
self.gatewayRemoteAddress = ScreenshotFixtureMode.gatewayAddress
|
||||
self.connectedGatewayID = ScreenshotFixtureMode.gatewayID
|
||||
@@ -5507,13 +5422,7 @@ extension NodeAppModel {
|
||||
self.gatewayConnected = true
|
||||
self.setOperatorConnected(true)
|
||||
self.hasOperatorAdminScope = true
|
||||
self.mainSessionBaseKey = "main"
|
||||
self.gatewaySessionScope = "per-sender"
|
||||
self.selectedAgentId = nil
|
||||
self.gatewayDefaultAgentId = "main"
|
||||
self.gatewayAgents = ScreenshotFixtureMode.agents
|
||||
self.focusedChatSessionKey = nil
|
||||
self.synchronizeTalkSessionKey()
|
||||
self.configureLocalGatewayFixtureSession(agents: ScreenshotFixtureMode.agents)
|
||||
self.talkMode.enterScreenshotFixtureMode()
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
@@ -6540,7 +6449,7 @@ extension NodeAppModel {
|
||||
.requestHistory(sessionKey: self.chatSessionKey)
|
||||
}
|
||||
|
||||
let items = Self.makeWatchChatItems(from: payload.messages ?? [])
|
||||
let items = WatchChatPresentation.makeItems(from: payload.messages ?? [])
|
||||
return WatchChatPreview(
|
||||
items: items,
|
||||
status: items.isEmpty
|
||||
@@ -6556,143 +6465,6 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func watchChatReplyText(
|
||||
from raw: [OpenClawKit.AnyCodable],
|
||||
runId: String,
|
||||
submittedText: String,
|
||||
submittedAtMs: Int64) -> String?
|
||||
{
|
||||
let entries = raw.compactMap(self.decodeWatchChatMessage)
|
||||
if let directReply = entries.last(where: {
|
||||
self.isTerminalWatchAssistant($0) && $0.message.idempotencyKey == runId
|
||||
}) {
|
||||
return directReply.text
|
||||
}
|
||||
|
||||
let userIdempotencyKey = "\(runId):user"
|
||||
let exactUserIndex = entries.lastIndex(where: {
|
||||
$0.message.role.lowercased() == "user" &&
|
||||
$0.message.idempotencyKey == userIdempotencyKey
|
||||
})
|
||||
let queuedUserIndex = entries.lastIndex(where: { entry in
|
||||
guard entry.message.role.lowercased() == "user",
|
||||
let timestampMs = self.watchTimestampMs(entry.message.timestamp),
|
||||
timestampMs >= submittedAtMs
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return entry.text.contains(submittedText)
|
||||
})
|
||||
guard let userIndex = exactUserIndex ?? queuedUserIndex else { return nil }
|
||||
return entries[(userIndex + 1)...].first(where: {
|
||||
self.isTerminalWatchAssistant($0)
|
||||
})?.text
|
||||
}
|
||||
|
||||
private nonisolated static func isTerminalWatchAssistant(_ entry: WatchChatMessageEntry) -> Bool {
|
||||
guard entry.message.role.lowercased() == "assistant" else { return false }
|
||||
if entry.isMessageToolMirror {
|
||||
return true
|
||||
}
|
||||
guard let stopReason = entry.message.stopReason?.lowercased() else { return false }
|
||||
// Tool-use rows can contain visible progress text, but a later assistant row owns the final reply.
|
||||
return stopReason != "tooluse" && stopReason != "tool_use" && stopReason != "tool_calls"
|
||||
}
|
||||
|
||||
private nonisolated static func decodeWatchChatMessage(
|
||||
_ raw: OpenClawKit.AnyCodable) -> WatchChatMessageEntry?
|
||||
{
|
||||
guard let data = try? JSONEncoder().encode(raw),
|
||||
let message = try? JSONDecoder().decode(OpenClawChatMessage.self, from: data),
|
||||
let text = nonEmptyWatchChatText(watchChatText(from: message))
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let metadata = try? JSONDecoder().decode(WatchChatMetadataEnvelope.self, from: data)
|
||||
return WatchChatMessageEntry(
|
||||
message: message,
|
||||
text: text,
|
||||
serverId: metadata?.metadata?.id,
|
||||
isMessageToolMirror: metadata?.messageToolMirror != nil)
|
||||
}
|
||||
|
||||
private nonisolated static func makeWatchChatItems(
|
||||
from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem]
|
||||
{
|
||||
let readableMessages = raw.compactMap(self.decodeWatchChatMessage)
|
||||
var idOccurrences: [String: Int] = [:]
|
||||
let identified = readableMessages.map { entry -> (WatchChatMessageEntry, String) in
|
||||
let baseId = entry.serverId.map { "\(entry.message.role)-\($0)" }
|
||||
?? self.watchChatFallbackKey(entry)
|
||||
idOccurrences[baseId, default: 0] += 1
|
||||
let stableId = "\(baseId)-\(idOccurrences[baseId]!)"
|
||||
return (entry, stableId)
|
||||
}
|
||||
return identified.suffix(self.watchChatPreviewItemLimit).map { entry, stableId in
|
||||
let timestampMs = self.watchTimestampMs(entry.message.timestamp)
|
||||
return OpenClawWatchChatItem(
|
||||
id: stableId,
|
||||
role: entry.message.role,
|
||||
text: self.truncatedWatchChatText(entry.text),
|
||||
timestampMs: timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func watchChatFallbackKey(_ entry: WatchChatMessageEntry) -> String {
|
||||
let timestamp = self.watchTimestampMs(entry.message.timestamp).map(String.init) ?? "missing"
|
||||
let source = "\(entry.message.role)\u{0}\(timestamp)\u{0}\(entry.text)"
|
||||
let digest = SHA256.hash(data: Data(source.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
return "\(entry.message.role)-\(digest)"
|
||||
}
|
||||
|
||||
private nonisolated static func watchChatText(from message: OpenClawChatMessage) -> String {
|
||||
let parts = message.content.compactMap { content -> String? in
|
||||
let kind = (content.type ?? "text").lowercased()
|
||||
guard kind.isEmpty || kind == "text" || kind == "output_text" else { return nil }
|
||||
if let text = self.nonEmptyWatchChatText(content.text) {
|
||||
return text
|
||||
}
|
||||
if let text = self.nonEmptyWatchChatText(content.content?.value as? String) {
|
||||
return text
|
||||
}
|
||||
if let dict = content.content?.value as? [String: OpenClawKit.AnyCodable],
|
||||
let text = self.nonEmptyWatchChatText(dict["text"]?.value as? String)
|
||||
{
|
||||
return text
|
||||
}
|
||||
return nil
|
||||
}
|
||||
let contentText = parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !contentText.isEmpty {
|
||||
return contentText
|
||||
}
|
||||
return message.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
|
||||
private nonisolated static func nonEmptyWatchChatText(_ text: String?) -> String? {
|
||||
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private nonisolated static func truncatedWatchChatText(_ text: String) -> String {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count > 240 else { return trimmed }
|
||||
return "\(trimmed.prefix(237))..."
|
||||
}
|
||||
|
||||
private nonisolated static func watchTimestampMs(_ timestamp: Double?) -> Int64? {
|
||||
guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil }
|
||||
let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000
|
||||
let maxReasonableEpochMs: Double = 32_503_680_000_000
|
||||
guard milliseconds.isFinite,
|
||||
milliseconds >= 0,
|
||||
milliseconds <= maxReasonableEpochMs
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return Int64(milliseconds)
|
||||
}
|
||||
|
||||
private func makeWatchAppSnapshot(
|
||||
chatPreview: WatchChatPreview? = nil) -> OpenClawWatchAppSnapshotMessage
|
||||
{
|
||||
@@ -7044,9 +6816,9 @@ extension NodeAppModel {
|
||||
return .sent
|
||||
}
|
||||
let history = try await appleReviewDemoChatTransport.requestHistory(sessionKey: sessionKey)
|
||||
if let replyText = Self.watchChatReplyText(
|
||||
if let replyText = WatchChatPresentation.replyText(
|
||||
from: history.messages ?? [],
|
||||
runId: response.runId,
|
||||
runID: response.runId,
|
||||
submittedText: text,
|
||||
submittedAtMs: submittedAtMs)
|
||||
{
|
||||
@@ -7156,9 +6928,9 @@ extension NodeAppModel {
|
||||
if let payload = try? await transport.requestHistory(
|
||||
sessionKey: sessionKey,
|
||||
ifCurrentRoute: expectedRoute),
|
||||
let replyText = Self.watchChatReplyText(
|
||||
let replyText = WatchChatPresentation.replyText(
|
||||
from: payload.messages ?? [],
|
||||
runId: runId,
|
||||
runID: runId,
|
||||
submittedText: submittedText,
|
||||
submittedAtMs: submittedAtMs)
|
||||
{
|
||||
@@ -7291,33 +7063,29 @@ extension NodeAppModel {
|
||||
let visiblePromptWasResolving = self.pendingExecApprovalPromptResolving
|
||||
let surfaceGenerationAtStart = self.pendingExecApprovalPromptSurfaceGeneration
|
||||
|
||||
let cachedPass = await self.readBackCachedWatchExecApprovalPrompts(
|
||||
prompts,
|
||||
gatewayStableID: gatewayStableID,
|
||||
reason: reason,
|
||||
syncSnapshots: syncSnapshots)
|
||||
let persistedPass = await self.readBackPersistedWatchExecApprovalReadbacks(
|
||||
persistedReadbacks,
|
||||
gatewayStableID: gatewayStableID,
|
||||
syncSnapshots: syncSnapshots)
|
||||
var classifiedApprovalIDs = cachedApprovalIDs
|
||||
classifiedApprovalIDs.formUnion(persistedReadbacks.compactMap {
|
||||
Self.execApprovalIDKey($0.approvalId)
|
||||
})
|
||||
let heldPass = await self.readBackHeldWatchExecApprovals(
|
||||
heldApprovalsByID,
|
||||
alreadyClassifiedApprovalIDs: classifiedApprovalIDs,
|
||||
var candidates: [WatchApprovalReadbackCandidate<ExecApprovalPrompt, PersistedExecApprovalReadback>] =
|
||||
prompts.map { .cached($0) } + persistedReadbacks.map { .persisted($0) }
|
||||
for heldApproval in heldApprovalsByID.values.sorted(by: {
|
||||
Self.approvalIDSortsBefore($0.approvalId, $1.approvalId)
|
||||
}) {
|
||||
guard let approvalID = Self.execApprovalIDKey(heldApproval.approvalId),
|
||||
classifiedApprovalIDs.insert(approvalID).inserted
|
||||
else { continue }
|
||||
candidates.append(.held(heldApproval))
|
||||
}
|
||||
// Cached cards, durable migration rows, then Watch-held actions must retain
|
||||
// their original order so presentation and write fences stay deterministic.
|
||||
let readback = await self.readBackWatchExecApprovals(
|
||||
candidates,
|
||||
gatewayStableID: gatewayStableID,
|
||||
reason: reason,
|
||||
syncSnapshots: syncSnapshots)
|
||||
// Concatenation order mirrors the original readback order: cached prompts,
|
||||
// persisted readbacks, then held Watch approvals.
|
||||
var loadedPrompts = cachedPass.loadedPrompts + persistedPass.loadedPrompts + heldPass.loadedPrompts
|
||||
let allReadbacksWereAuthoritative = cachedPass.allReadbacksWereAuthoritative &&
|
||||
persistedPass.allReadbacksWereAuthoritative &&
|
||||
heldPass.allReadbacksWereAuthoritative
|
||||
|
||||
guard allReadbacksWereAuthoritative else { return false }
|
||||
guard readback.isAuthoritative else { return false }
|
||||
var loadedPrompts = readback.loadedPrompts
|
||||
|
||||
// Readbacks can interleave with terminal events while awaiting other owners.
|
||||
// Re-check the live owner table instead of replaying the stale local array.
|
||||
@@ -7378,7 +7146,7 @@ extension NodeAppModel {
|
||||
syncSnapshots: syncSnapshots)
|
||||
}
|
||||
|
||||
guard let selectedPhonePrompt else { return allReadbacksWereAuthoritative }
|
||||
guard let selectedPhonePrompt else { return true }
|
||||
let selectedPromptWasResolving = visiblePromptWasResolving &&
|
||||
visiblePromptAtStart.map { Self.approvalIDsMatch($0.id, selectedPhonePrompt.id) } == true &&
|
||||
visiblePromptNow.map { Self.approvalIDsMatch($0.id, selectedPhonePrompt.id) } == true &&
|
||||
@@ -7391,143 +7159,85 @@ extension NodeAppModel {
|
||||
self.pendingExecApprovalPromptErrorText =
|
||||
"The previous decision was not recorded. Review and try again."
|
||||
}
|
||||
return allReadbacksWereAuthoritative
|
||||
return true
|
||||
}
|
||||
|
||||
private struct WatchExecApprovalReadbackPass {
|
||||
private func readBackWatchExecApprovals(
|
||||
_ candidates: [WatchApprovalReadbackCandidate<ExecApprovalPrompt, PersistedExecApprovalReadback>],
|
||||
gatewayStableID: String,
|
||||
reason: String,
|
||||
syncSnapshots: Bool) async -> (loadedPrompts: [ExecApprovalPrompt], isAuthoritative: Bool)
|
||||
{
|
||||
var loadedPrompts: [ExecApprovalPrompt] = []
|
||||
var allReadbacksWereAuthoritative = true
|
||||
}
|
||||
|
||||
private func readBackCachedWatchExecApprovalPrompts(
|
||||
_ prompts: [ExecApprovalPrompt],
|
||||
gatewayStableID: String,
|
||||
reason: String,
|
||||
syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass
|
||||
{
|
||||
var pass = WatchExecApprovalReadbackPass()
|
||||
for cachedPrompt in prompts {
|
||||
let persistedReadback = PersistedExecApprovalReadback(
|
||||
approvalId: cachedPrompt.id,
|
||||
gatewayStableID: cachedPrompt.gatewayStableID)
|
||||
let readback = await self.fetchExecApprovalPrompt(
|
||||
approvalId: cachedPrompt.id,
|
||||
sourceReason: reason)
|
||||
switch readback {
|
||||
case let .loaded(prompt):
|
||||
self.upsertWatchExecApprovalPrompt(prompt)
|
||||
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
||||
pass.loadedPrompts.append(prompt)
|
||||
case let .terminal(terminal):
|
||||
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
||||
let outcome = await self.applyCanonicalExecApprovalTerminal(
|
||||
terminal,
|
||||
appliedHere: false,
|
||||
gatewayStableID: gatewayStableID,
|
||||
syncSnapshots: syncSnapshots)
|
||||
if case .failed = outcome {
|
||||
pass.allReadbacksWereAuthoritative = false
|
||||
}
|
||||
case .stale:
|
||||
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
||||
self.markPendingExecApprovalTerminal(
|
||||
approvalId: cachedPrompt.id,
|
||||
outcome: ExecApprovalOutcome(
|
||||
text: "This approval is no longer available.",
|
||||
tone: .warning))
|
||||
await self.publishWatchExecApprovalExpired(
|
||||
approvalId: cachedPrompt.id,
|
||||
gatewayStableID: gatewayStableID,
|
||||
reason: .notFound,
|
||||
syncSnapshots: syncSnapshots)
|
||||
case .failed:
|
||||
pass.allReadbacksWereAuthoritative = false
|
||||
var isAuthoritative = true
|
||||
for candidate in candidates {
|
||||
let approvalID: String
|
||||
let persistedReadback: PersistedExecApprovalReadback?
|
||||
let fetchReason: String
|
||||
let markVisiblePromptStale: Bool
|
||||
switch candidate {
|
||||
case let .cached(prompt):
|
||||
approvalID = prompt.id
|
||||
persistedReadback = PersistedExecApprovalReadback(
|
||||
approvalId: prompt.id,
|
||||
gatewayStableID: prompt.gatewayStableID)
|
||||
fetchReason = reason
|
||||
markVisiblePromptStale = true
|
||||
case let .persisted(readback):
|
||||
approvalID = readback.approvalId
|
||||
persistedReadback = readback
|
||||
fetchReason = "persisted_upgrade"
|
||||
markVisiblePromptStale = false
|
||||
case let .held(approval):
|
||||
approvalID = approval.approvalId
|
||||
persistedReadback = nil
|
||||
fetchReason = reason
|
||||
markVisiblePromptStale = false
|
||||
}
|
||||
}
|
||||
return pass
|
||||
}
|
||||
|
||||
private func readBackPersistedWatchExecApprovalReadbacks(
|
||||
_ persistedReadbacks: [PersistedExecApprovalReadback],
|
||||
gatewayStableID: String,
|
||||
syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass
|
||||
{
|
||||
var pass = WatchExecApprovalReadbackPass()
|
||||
for persistedReadback in persistedReadbacks {
|
||||
let readback = await self.fetchExecApprovalPrompt(
|
||||
approvalId: persistedReadback.approvalId,
|
||||
sourceReason: "persisted_upgrade")
|
||||
switch readback {
|
||||
case let .loaded(prompt):
|
||||
self.upsertWatchExecApprovalPrompt(prompt)
|
||||
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
||||
pass.loadedPrompts.append(prompt)
|
||||
case let .terminal(terminal):
|
||||
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
||||
let outcome = await self.applyCanonicalExecApprovalTerminal(
|
||||
terminal,
|
||||
appliedHere: false,
|
||||
gatewayStableID: gatewayStableID,
|
||||
syncSnapshots: syncSnapshots)
|
||||
if case .failed = outcome {
|
||||
pass.allReadbacksWereAuthoritative = false
|
||||
}
|
||||
case .stale:
|
||||
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
||||
await self.publishWatchExecApprovalExpired(
|
||||
approvalId: persistedReadback.approvalId,
|
||||
gatewayStableID: gatewayStableID,
|
||||
reason: .notFound,
|
||||
syncSnapshots: syncSnapshots)
|
||||
case .failed:
|
||||
pass.allReadbacksWereAuthoritative = false
|
||||
}
|
||||
}
|
||||
return pass
|
||||
}
|
||||
|
||||
private func readBackHeldWatchExecApprovals(
|
||||
_ heldApprovalsByID: [ExecApprovalIdentifier.Key: WatchExecApprovalSnapshotRequestItem],
|
||||
alreadyClassifiedApprovalIDs: Set<ExecApprovalIdentifier.Key>,
|
||||
gatewayStableID: String,
|
||||
reason: String,
|
||||
syncSnapshots: Bool) async -> WatchExecApprovalReadbackPass
|
||||
{
|
||||
var pass = WatchExecApprovalReadbackPass()
|
||||
var classifiedApprovalIDs = alreadyClassifiedApprovalIDs
|
||||
for heldApproval in heldApprovalsByID.values.sorted(by: {
|
||||
Self.approvalIDSortsBefore($0.approvalId, $1.approvalId)
|
||||
}) {
|
||||
guard let approvalID = Self.execApprovalIDKey(heldApproval.approvalId),
|
||||
classifiedApprovalIDs.insert(approvalID).inserted
|
||||
else { continue }
|
||||
switch await self.fetchExecApprovalPrompt(
|
||||
approvalId: heldApproval.approvalId,
|
||||
sourceReason: reason)
|
||||
approvalId: approvalID,
|
||||
sourceReason: fetchReason)
|
||||
{
|
||||
case let .loaded(prompt):
|
||||
self.upsertWatchExecApprovalPrompt(prompt)
|
||||
pass.loadedPrompts.append(prompt)
|
||||
if let persistedReadback {
|
||||
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
||||
}
|
||||
loadedPrompts.append(prompt)
|
||||
case let .terminal(terminal):
|
||||
if let persistedReadback {
|
||||
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
||||
}
|
||||
let outcome = await self.applyCanonicalExecApprovalTerminal(
|
||||
terminal,
|
||||
appliedHere: false,
|
||||
gatewayStableID: gatewayStableID,
|
||||
syncSnapshots: syncSnapshots)
|
||||
if case .failed = outcome {
|
||||
pass.allReadbacksWereAuthoritative = false
|
||||
isAuthoritative = false
|
||||
}
|
||||
case .stale:
|
||||
if let persistedReadback {
|
||||
self.removePendingPersistedExecApprovalReadback(persistedReadback)
|
||||
}
|
||||
if markVisiblePromptStale {
|
||||
self.markPendingExecApprovalTerminal(
|
||||
approvalId: approvalID,
|
||||
outcome: ExecApprovalOutcome(
|
||||
text: "This approval is no longer available.",
|
||||
tone: .warning))
|
||||
}
|
||||
await self.publishWatchExecApprovalExpired(
|
||||
approvalId: heldApproval.approvalId,
|
||||
approvalId: approvalID,
|
||||
gatewayStableID: gatewayStableID,
|
||||
reason: .notFound,
|
||||
syncSnapshots: syncSnapshots)
|
||||
case .failed:
|
||||
pass.allReadbacksWereAuthoritative = false
|
||||
isAuthoritative = false
|
||||
}
|
||||
}
|
||||
return pass
|
||||
return (loadedPrompts, isAuthoritative)
|
||||
}
|
||||
|
||||
private nonisolated static func watchExecApprovalIDsNeedingFetch(
|
||||
@@ -10093,44 +9803,32 @@ extension NodeAppModel {
|
||||
var durationMs: Int
|
||||
}
|
||||
|
||||
private func waitForGatewayConnection(timeoutMs: Int, pollMs: Int) async -> Bool {
|
||||
private func waitForGatewayConnection(
|
||||
timeoutMs: Int,
|
||||
pollMs: Int,
|
||||
owner: GatewayConnectionWaitOwner = .node) async -> Bool
|
||||
{
|
||||
let clampedTimeoutMs = max(0, timeoutMs)
|
||||
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
|
||||
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
|
||||
let isConnected: @MainActor () -> Bool = {
|
||||
switch owner {
|
||||
case .node:
|
||||
self.gatewayConnected
|
||||
case .operator:
|
||||
self.operatorConnected
|
||||
}
|
||||
}
|
||||
while Date() < deadline {
|
||||
if Task.isCancelled {
|
||||
return false
|
||||
}
|
||||
if await isGatewayConnected() {
|
||||
return true
|
||||
}
|
||||
if Task.isCancelled { return false }
|
||||
if isConnected() { return true }
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: pollIntervalNs)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return await isGatewayConnected()
|
||||
}
|
||||
|
||||
private func waitForOperatorConnection(timeoutMs: Int, pollMs: Int) async -> Bool {
|
||||
let clampedTimeoutMs = max(0, timeoutMs)
|
||||
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
|
||||
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
|
||||
while Date() < deadline {
|
||||
if Task.isCancelled {
|
||||
return false
|
||||
}
|
||||
if await self.isOperatorConnected() {
|
||||
return true
|
||||
}
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: pollIntervalNs)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return await self.isOperatorConnected()
|
||||
return isConnected()
|
||||
}
|
||||
|
||||
private func ensureOperatorReconnectLoopIfNeeded() {
|
||||
@@ -10217,7 +9915,11 @@ extension NodeAppModel {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_wait "
|
||||
+ "reason=\(reconnectReason) phase=initial timeoutMs=\(initialWaitMs)")
|
||||
if await self.waitForOperatorConnection(timeoutMs: initialWaitMs, pollMs: 200) {
|
||||
if await self.waitForGatewayConnection(
|
||||
timeoutMs: initialWaitMs,
|
||||
pollMs: 200,
|
||||
owner: .operator)
|
||||
{
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_connected "
|
||||
+ "reason=\(reconnectReason) phase=initial")
|
||||
@@ -10247,7 +9949,10 @@ extension NodeAppModel {
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_wait "
|
||||
+ "reason=\(reconnectReason) phase=restart timeoutMs=\(remainingWaitMs)")
|
||||
let connected = await waitForOperatorConnection(timeoutMs: remainingWaitMs, pollMs: 200)
|
||||
let connected = await self.waitForGatewayConnection(
|
||||
timeoutMs: remainingWaitMs,
|
||||
pollMs: 200,
|
||||
owner: .operator)
|
||||
GatewayDiagnostics.log(
|
||||
"watch exec approval: watch_request_reconnect_\(connected ? "connected" : "timeout") "
|
||||
+ "reason=\(reconnectReason) phase=restart")
|
||||
@@ -10259,7 +9964,10 @@ extension NodeAppModel {
|
||||
return true
|
||||
}
|
||||
self.ensureOperatorReconnectLoopIfNeeded()
|
||||
return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250)
|
||||
return await self.waitForGatewayConnection(
|
||||
timeoutMs: timeoutMs,
|
||||
pollMs: 250,
|
||||
owner: .operator)
|
||||
}
|
||||
|
||||
private func performBackgroundAliveBeaconIfNeeded(
|
||||
@@ -10864,7 +10572,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] {
|
||||
self.makeWatchChatItems(from: raw)
|
||||
WatchChatPresentation.makeItems(from: raw)
|
||||
}
|
||||
|
||||
nonisolated static func _test_watchChatReplyText(
|
||||
@@ -10873,9 +10581,9 @@ extension NodeAppModel {
|
||||
submittedText: String,
|
||||
submittedAtMs: Int64) -> String?
|
||||
{
|
||||
self.watchChatReplyText(
|
||||
WatchChatPresentation.replyText(
|
||||
from: raw,
|
||||
runId: runId,
|
||||
runID: runId,
|
||||
submittedText: submittedText,
|
||||
submittedAtMs: submittedAtMs)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,160 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
|
||||
/// Three recovery sources represent the same gateway-owned approval readback.
|
||||
/// Preserve their source so cached cards, migration rows, and held Watch actions
|
||||
/// share one classifier without losing source-specific cleanup.
|
||||
enum WatchApprovalReadbackCandidate<Prompt, PersistedReadback> {
|
||||
case cached(Prompt)
|
||||
case persisted(PersistedReadback)
|
||||
case held(WatchExecApprovalSnapshotRequestItem)
|
||||
}
|
||||
|
||||
/// Owns the one canonical interpretation of projected chat rows shared by Watch
|
||||
/// previews and run-correlated voice replies.
|
||||
enum WatchChatPresentation {
|
||||
private static let previewItemLimit = 5
|
||||
|
||||
private struct MetadataEnvelope: Decodable {
|
||||
struct Metadata: Decodable {
|
||||
var id: String?
|
||||
}
|
||||
|
||||
var metadata: Metadata?
|
||||
var messageToolMirror: [String: String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case metadata = "__openclaw"
|
||||
case messageToolMirror = "openclawMessageToolMirror"
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageEntry {
|
||||
var message: OpenClawChatMessage
|
||||
var text: String
|
||||
var serverID: String?
|
||||
var isMessageToolMirror: Bool
|
||||
}
|
||||
|
||||
nonisolated static func replyText(
|
||||
from rawMessages: [OpenClawKit.AnyCodable],
|
||||
runID: String,
|
||||
submittedText: String,
|
||||
submittedAtMs: Int64) -> String?
|
||||
{
|
||||
let entries = rawMessages.compactMap(Self.decodeMessage)
|
||||
if let directReply = entries.last(where: {
|
||||
Self.isTerminalAssistant($0) && $0.message.idempotencyKey == runID
|
||||
}) {
|
||||
return directReply.text
|
||||
}
|
||||
|
||||
let userIdempotencyKey = "\(runID):user"
|
||||
let exactUserIndex = entries.lastIndex(where: {
|
||||
$0.message.role.lowercased() == "user" &&
|
||||
$0.message.idempotencyKey == userIdempotencyKey
|
||||
})
|
||||
let queuedUserIndex = entries.lastIndex(where: { entry in
|
||||
guard entry.message.role.lowercased() == "user",
|
||||
let timestampMs = Self.timestampMs(entry.message.timestamp),
|
||||
timestampMs >= submittedAtMs
|
||||
else { return false }
|
||||
return entry.text.contains(submittedText)
|
||||
})
|
||||
guard let userIndex = exactUserIndex ?? queuedUserIndex else { return nil }
|
||||
return entries[(userIndex + 1)...].first(where: Self.isTerminalAssistant)?.text
|
||||
}
|
||||
|
||||
nonisolated static func makeItems(
|
||||
from rawMessages: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem]
|
||||
{
|
||||
var occurrences: [String: Int] = [:]
|
||||
let identified = rawMessages.compactMap(Self.decodeMessage).map { entry in
|
||||
let baseID = entry.serverID.map { "\(entry.message.role)-\($0)" }
|
||||
?? Self.fallbackKey(entry)
|
||||
occurrences[baseID, default: 0] += 1
|
||||
return (entry, "\(baseID)-\(occurrences[baseID]!)")
|
||||
}
|
||||
return identified.suffix(Self.previewItemLimit).map { entry, stableID in
|
||||
OpenClawWatchChatItem(
|
||||
id: stableID,
|
||||
role: entry.message.role,
|
||||
text: Self.truncatedText(entry.text),
|
||||
timestampMs: Self.timestampMs(entry.message.timestamp))
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func isTerminalAssistant(_ entry: MessageEntry) -> Bool {
|
||||
guard entry.message.role.lowercased() == "assistant" else { return false }
|
||||
if entry.isMessageToolMirror { return true }
|
||||
guard let stopReason = entry.message.stopReason?.lowercased() else { return false }
|
||||
// Progress rows are not final replies; the later assistant row owns completion.
|
||||
return stopReason != "tooluse" && stopReason != "tool_use" && stopReason != "tool_calls"
|
||||
}
|
||||
|
||||
private nonisolated static func decodeMessage(_ raw: OpenClawKit.AnyCodable) -> MessageEntry? {
|
||||
guard let data = try? JSONEncoder().encode(raw),
|
||||
let message = try? JSONDecoder().decode(OpenClawChatMessage.self, from: data),
|
||||
let text = nonEmptyText(messageText(message))
|
||||
else { return nil }
|
||||
let metadata = try? JSONDecoder().decode(MetadataEnvelope.self, from: data)
|
||||
return MessageEntry(
|
||||
message: message,
|
||||
text: text,
|
||||
serverID: metadata?.metadata?.id,
|
||||
isMessageToolMirror: metadata?.messageToolMirror != nil)
|
||||
}
|
||||
|
||||
private nonisolated static func fallbackKey(_ entry: MessageEntry) -> String {
|
||||
let timestamp = Self.timestampMs(entry.message.timestamp).map(String.init) ?? "missing"
|
||||
let source = "\(entry.message.role)\u{0}\(timestamp)\u{0}\(entry.text)"
|
||||
let digest = SHA256.hash(data: Data(source.utf8))
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
return "\(entry.message.role)-\(digest)"
|
||||
}
|
||||
|
||||
private nonisolated static func messageText(_ message: OpenClawChatMessage) -> String {
|
||||
let parts = message.content.compactMap { content -> String? in
|
||||
let kind = (content.type ?? "text").lowercased()
|
||||
guard kind.isEmpty || kind == "text" || kind == "output_text" else { return nil }
|
||||
if let text = Self.nonEmptyText(content.text) { return text }
|
||||
if let text = Self.nonEmptyText(content.content?.value as? String) { return text }
|
||||
if let values = content.content?.value as? [String: OpenClawKit.AnyCodable] {
|
||||
return Self.nonEmptyText(values["text"]?.value as? String)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
let contentText = parts.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return contentText.isEmpty
|
||||
? message.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
: contentText
|
||||
}
|
||||
|
||||
private nonisolated static func nonEmptyText(_ text: String?) -> String? {
|
||||
let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private nonisolated static func truncatedText(_ text: String) -> String {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count > 240 else { return trimmed }
|
||||
return "\(trimmed.prefix(237))..."
|
||||
}
|
||||
|
||||
private nonisolated static func timestampMs(_ timestamp: Double?) -> Int64? {
|
||||
guard let timestamp, timestamp.isFinite, timestamp >= 0 else { return nil }
|
||||
let milliseconds = timestamp > 100_000_000_000 ? timestamp : timestamp * 1000
|
||||
guard milliseconds.isFinite,
|
||||
milliseconds >= 0,
|
||||
milliseconds <= 32_503_680_000_000
|
||||
else { return nil }
|
||||
return Int64(milliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WatchMessageOutbox {
|
||||
|
||||
@@ -1,6 +1,71 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
/// Keeps notification failures Sendable across the system prompt and timeout tasks.
|
||||
struct NotificationCallError: Error {
|
||||
let message: String
|
||||
}
|
||||
|
||||
private final class NotificationInvokeLatch<Value: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<Result<Value, NotificationCallError>, Never>?
|
||||
private var resumed = false
|
||||
|
||||
func setContinuation(_ continuation: CheckedContinuation<Result<Value, NotificationCallError>, Never>) {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
self.continuation = continuation
|
||||
}
|
||||
|
||||
func resume(_ result: Result<Value, NotificationCallError>) {
|
||||
self.lock.lock()
|
||||
guard !self.resumed else {
|
||||
self.lock.unlock()
|
||||
return
|
||||
}
|
||||
self.resumed = true
|
||||
let continuation = self.continuation
|
||||
self.continuation = nil
|
||||
self.lock.unlock()
|
||||
continuation?.resume(returning: result)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum NotificationOperationRunner {
|
||||
/// System permission prompts can stall indefinitely; the latch makes the timeout
|
||||
/// and operation race resume exactly once without blocking the app's main actor.
|
||||
static func run<Value: Sendable>(
|
||||
timeoutSeconds: Double,
|
||||
operation: @escaping @Sendable () async throws -> Value) async -> Result<Value, NotificationCallError>
|
||||
{
|
||||
let latch = NotificationInvokeLatch<Value>()
|
||||
var operationTask: Task<Void, Never>?
|
||||
var timeoutTask: Task<Void, Never>?
|
||||
defer {
|
||||
operationTask?.cancel()
|
||||
timeoutTask?.cancel()
|
||||
}
|
||||
let timeout = max(0, timeoutSeconds)
|
||||
return await withCheckedContinuation { continuation in
|
||||
latch.setContinuation(continuation)
|
||||
operationTask = Task { @MainActor in
|
||||
do {
|
||||
try await latch.resume(.success(operation()))
|
||||
} catch {
|
||||
latch.resume(.failure(NotificationCallError(message: error.localizedDescription)))
|
||||
}
|
||||
}
|
||||
timeoutTask = Task.detached {
|
||||
if timeout > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||
}
|
||||
latch.resume(.failure(NotificationCallError(message: "notification request timed out")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationSnapshot: @unchecked Sendable {
|
||||
let identifier: String
|
||||
let userInfo: [AnyHashable: Any]
|
||||
|
||||
@@ -88,6 +88,18 @@ private actor ApprovalResolutionCapture {
|
||||
}
|
||||
}
|
||||
|
||||
private actor WatchApprovalReadbackProbe {
|
||||
private var approvalIDs: [String] = []
|
||||
|
||||
func record(_ approvalID: String) {
|
||||
self.approvalIDs.append(approvalID)
|
||||
}
|
||||
|
||||
func snapshot() -> [String] {
|
||||
self.approvalIDs
|
||||
}
|
||||
}
|
||||
|
||||
private actor MockHealthSummaryService: HealthSummaryServicing {
|
||||
private(set) var periods: [OpenClawHealthSummaryPeriod] = []
|
||||
|
||||
@@ -4474,6 +4486,65 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||
#expect(snapshot.approvals.map(\.id) == approvalIDs.sorted())
|
||||
}
|
||||
|
||||
@Test @MainActor func `watch readback classifies cached persisted and held approvals once in owner order`()
|
||||
async throws
|
||||
{
|
||||
NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState()
|
||||
defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() }
|
||||
NodeAppModel._test_setPersistedWatchExecApprovalBridgeStateJSON(#"""
|
||||
{
|
||||
"approvals": [],
|
||||
"pendingApprovalReadbacks": [{
|
||||
"approvalId": "approval-persisted-owner",
|
||||
"gatewayStableID": "test-gateway"
|
||||
}]
|
||||
}
|
||||
"""#)
|
||||
let watchService = MockWatchMessagingService()
|
||||
let appModel = NodeAppModel(
|
||||
notificationCenter: MockBootstrapNotificationCenter(),
|
||||
watchMessagingService: watchService)
|
||||
appModel._test_setConnectedGatewayID("test-gateway")
|
||||
try appModel._test_presentExecApprovalPrompt(#require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-cached-owner",
|
||||
commandText: "echo cached",
|
||||
expiresAtMs: 4_000_000_000_000)))
|
||||
|
||||
let expectedApprovalIDs = [
|
||||
"approval-cached-owner",
|
||||
"approval-persisted-owner",
|
||||
"approval-held-owner",
|
||||
]
|
||||
let probe = WatchApprovalReadbackProbe()
|
||||
appModel._test_setUnifiedExecApprovalGetResponses(
|
||||
expectedApprovalIDs.map {
|
||||
(approvalID: $0, json: makePendingExecApprovalJSON($0))
|
||||
},
|
||||
beforeResponse: { approvalID in
|
||||
await probe.record(approvalID)
|
||||
})
|
||||
|
||||
await appModel._test_refreshWatchExecApprovalSnapshotOnDemand(
|
||||
WatchExecApprovalSnapshotRequestEvent(
|
||||
requestId: "snapshot-canonical-owner-order",
|
||||
gatewayStableID: "test-gateway",
|
||||
heldApprovals: [WatchExecApprovalSnapshotRequestItem(
|
||||
approvalId: "approval-held-owner",
|
||||
activeResolutionAttemptId: nil)],
|
||||
sentAtMs: 226,
|
||||
transport: "sendMessage"))
|
||||
|
||||
#expect(await probe.snapshot() == expectedApprovalIDs)
|
||||
let snapshot = try #require(watchService.sentExecApprovalSnapshots.first {
|
||||
$0.requestId == "snapshot-canonical-owner-order"
|
||||
})
|
||||
#expect(snapshot.requestId == "snapshot-canonical-owner-order")
|
||||
#expect(snapshot.requestGatewayStableID == "test-gateway")
|
||||
#expect(snapshot.approvals.map(\.id) == expectedApprovalIDs.sorted())
|
||||
#expect(appModel._test_pendingPersistedExecApprovalReadbacks().isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func `canonical watch refresh does not acknowledge byte distinct owner`() async throws {
|
||||
NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState()
|
||||
defer { NodeAppModel._test_resetPersistedWatchExecApprovalBridgeState() }
|
||||
@@ -5583,6 +5654,43 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
|
||||
#expect(items.map(\.text) == ["Still worth reading"])
|
||||
}
|
||||
|
||||
@Test func `canonical watch chat owner keeps the last five readable projected rows`() throws {
|
||||
let rawMessages = try (0..<7).map { index in
|
||||
try makeWatchChatRawMessage(
|
||||
role: "assistant",
|
||||
text: "Readable message \(index)",
|
||||
timestamp: Double(index + 1))
|
||||
}
|
||||
|
||||
let items = WatchChatPresentation.makeItems(from: rawMessages)
|
||||
|
||||
#expect(items.map(\.text) == (2..<7).map { "Readable message \($0)" })
|
||||
}
|
||||
|
||||
@Test func `canonical watch chat owner anchors terminal tool mirrors to the submitted turn`() throws {
|
||||
let rawMessages = try [
|
||||
makeWatchChatRawMessage(
|
||||
role: "user",
|
||||
text: "Send the update",
|
||||
timestamp: 3000,
|
||||
idempotencyKey: "watch-run:user"),
|
||||
makeProjectedWatchChatRawMessage(
|
||||
role: "assistant",
|
||||
text: "Update sent",
|
||||
timestamp: 4000,
|
||||
serverId: "tool-result-1",
|
||||
isMessageToolMirror: true),
|
||||
]
|
||||
|
||||
let reply = WatchChatPresentation.replyText(
|
||||
from: rawMessages,
|
||||
runID: "watch-run",
|
||||
submittedText: "Send the update",
|
||||
submittedAtMs: 2500)
|
||||
|
||||
#expect(reply == "Update sent")
|
||||
}
|
||||
|
||||
@Test func `watch chat preview reads responses output text`() throws {
|
||||
let rawMessages = try [
|
||||
makeWatchChatRawMessage(
|
||||
|
||||
@@ -2,6 +2,14 @@ import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
private enum NotificationOperationProbeError: LocalizedError {
|
||||
case expected
|
||||
|
||||
var errorDescription: String? {
|
||||
"expected notification failure"
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationServingPreferenceTests {
|
||||
@Test func `defaults to enabled`() throws {
|
||||
let (suiteName, defaults) = try self.makeDefaults()
|
||||
@@ -21,6 +29,46 @@ struct NotificationServingPreferenceTests {
|
||||
#expect(NotificationServingPreference.isEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
@Test @MainActor func `notification operation returns its completed value`() async {
|
||||
let result = await NotificationOperationRunner.run(timeoutSeconds: 1) { 42 }
|
||||
|
||||
switch result {
|
||||
case let .success(value):
|
||||
#expect(value == 42)
|
||||
case let .failure(error):
|
||||
Issue.record("Unexpected notification failure: \(error.message)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func `notification operation preserves its failure`() async {
|
||||
let result: Result<Int, NotificationCallError> = await NotificationOperationRunner.run(
|
||||
timeoutSeconds: 1)
|
||||
{
|
||||
throw NotificationOperationProbeError.expected
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
Issue.record("The failed notification operation unexpectedly succeeded")
|
||||
case let .failure(error):
|
||||
#expect(error.message == "expected notification failure")
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func `notification operation times out suspended work`() async {
|
||||
let result = await NotificationOperationRunner.run(timeoutSeconds: 0.01) {
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
return 42
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
Issue.record("The suspended notification operation unexpectedly succeeded")
|
||||
case let .failure(error):
|
||||
#expect(error.message == "notification request timed out")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeDefaults() throws -> (String, UserDefaults) {
|
||||
let suiteName = "NotificationServingPreferenceTests.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
|
||||
Reference in New Issue
Block a user