mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-26 10:13:27 +08:00
fix: Mac node reports app update when bundled worker cannot read newer state (#157948)
Explain when the Mac app’s bundled worker cannot read a newer state schema, and direct the operator to update the app rather than only the core package. Offer the existing updater action only when it is available. Preserve worker schema enforcement and capability readiness. Include focused status mapping and native accessibility coverage. The Developer ID-signed, end-to-end update/replacement sequence remains untested; this change does not modify that updater implementation. Co-authored-by: PollyBot13 <pollybot13@gmail.com> Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
1 parent
7666cccd01
commit
6199dfe4fd
8 files changed
+240
-27
No files matched your search
Generated
+1
@@ -3554,6 +3554,7 @@
|
||||
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.9e4f0aae5dc1d7a9","source":"OpenClaw active"},
|
||||
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.0d72c4f3045a12a1","source":"Pairing approval pending"},
|
||||
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.4746d52ca761661c","source":"Show pairing requests"},
|
||||
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.13213edf4beb15a9","source":"Update"},
|
||||
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.82701e2dbfc77dd1","source":"Voice Wake"},
|
||||
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.bde7b5b40ae21c79","source":"local"},
|
||||
{"path":"apps/macos/Sources/OpenClaw/StatusMenuHeaderView.swift","kind":"ui-localized-call","surface":"apple","id":"native.apple.af7a06a9db8426bd","source":"remote"},
|
||||
|
||||
@@ -6,6 +6,10 @@ import Observation
|
||||
/// health from gateway node listings, so a node channel that never dials still
|
||||
/// surfaces its reason to the operator.
|
||||
enum MacNodeChannelState: Equatable, Sendable {
|
||||
enum RecoveryAction: Equatable, Sendable {
|
||||
case checkForUpdates
|
||||
}
|
||||
|
||||
/// Node mode is paused, stopped, or not configured to run.
|
||||
case idle
|
||||
/// The channel connected. A non-nil reason means the node-host worker is
|
||||
@@ -14,17 +18,48 @@ enum MacNodeChannelState: Equatable, Sendable {
|
||||
/// The last connect attempt failed; the coordinator keeps retrying.
|
||||
case unavailable(reason: String, diagnostic: String? = nil)
|
||||
|
||||
var operatorStatusLine: (label: String, diagnostic: String?, isDegraded: Bool)? {
|
||||
var operatorStatusLine: (
|
||||
label: String,
|
||||
diagnostic: String?,
|
||||
isDegraded: Bool,
|
||||
recoveryAction: RecoveryAction?)?
|
||||
{
|
||||
switch self {
|
||||
case .idle, .connected(workerUnavailableReason: nil, diagnostic: _):
|
||||
nil
|
||||
return nil
|
||||
case let .connected(workerUnavailableReason: .some(reason), diagnostic: diagnostic):
|
||||
("Mac node degraded — \(Self.condense(reason))", Self.excerpt(diagnostic), true)
|
||||
let compatibility = Self.workerSchemaMismatch(in: reason + "\n" + (diagnostic ?? ""))
|
||||
let label = compatibility.map {
|
||||
"Mac node degraded — update the OpenClaw app: its worker supports schema \($0.supported), " +
|
||||
"but shared state requires schema \($0.required)"
|
||||
} ?? "Mac node degraded — \(Self.condense(reason))"
|
||||
return (label, Self.excerpt(diagnostic), true, compatibility == nil ? nil : .checkForUpdates)
|
||||
case let .unavailable(reason, diagnostic):
|
||||
("Mac node unavailable — \(Self.condense(reason))", Self.excerpt(diagnostic), false)
|
||||
let compatibility = Self.workerSchemaMismatch(in: reason + "\n" + (diagnostic ?? ""))
|
||||
let label = compatibility.map {
|
||||
"Mac node unavailable — update the OpenClaw app: its worker supports schema \($0.supported), " +
|
||||
"but shared state requires schema \($0.required)"
|
||||
} ?? "Mac node unavailable — \(Self.condense(reason))"
|
||||
return (label, Self.excerpt(diagnostic), false, compatibility == nil ? nil : .checkForUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
private static func workerSchemaMismatch(in message: String) -> (required: Int, supported: Int)? {
|
||||
let marker = "uses newer schema version "
|
||||
guard let markerRange = message.range(of: marker),
|
||||
let separatorRange = message[markerRange.upperBound...].range(of: "; this build supports ")
|
||||
else { return nil }
|
||||
|
||||
let requiredText = message[markerRange.upperBound..<separatorRange.lowerBound]
|
||||
let supportedSuffix = message[separatorRange.upperBound...]
|
||||
let supportedText = supportedSuffix.prefix(while: \.isNumber)
|
||||
guard let required = Int(requiredText),
|
||||
let supported = Int(supportedText),
|
||||
required > supported
|
||||
else { return nil }
|
||||
return (required, supported)
|
||||
}
|
||||
|
||||
/// Menu status lines are single-line; keep the leading reason sentence and
|
||||
/// bound it so a CLI stack trace cannot flood the menu.
|
||||
private static func condense(_ reason: String) -> String {
|
||||
|
||||
@@ -50,9 +50,6 @@ final class StatusMenuController: NSObject, NSMenuDelegate {
|
||||
StatusMenuAppearance.pin(self.menu)
|
||||
|
||||
let renderer = StatusMenuRenderer(menu: self.menu, state: self.state, approvalQueue: self.approvals)
|
||||
renderer.onInstallUpdate = { [weak self] in
|
||||
self?.updater.checkForUpdates(nil)
|
||||
}
|
||||
self.renderer = renderer
|
||||
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||
@@ -245,6 +242,14 @@ final class StatusMenuController: NSObject, NSMenuDelegate {
|
||||
mainSessionKey: self.activityStore.mainSessionKey,
|
||||
approvals: self.approvals.requests,
|
||||
gateways: DashboardGatewayMenuModel.items(from: self.dashboard.gatewayEntries))
|
||||
if self.updater.isAvailable {
|
||||
self.renderer?.onInstallUpdate = { [weak self] in
|
||||
guard let self, self.updater.isAvailable else { return }
|
||||
self.updater.checkForUpdates(nil)
|
||||
}
|
||||
} else {
|
||||
self.renderer?.onInstallUpdate = nil
|
||||
}
|
||||
self.renderer?.isSleeping = statusMenuGatewayIsSleeping(state: self.state)
|
||||
self.renderer?.reconcile(StatusMenuDescriptor.build(from: snapshot))
|
||||
}
|
||||
|
||||
@@ -17,10 +17,16 @@ struct StatusMenuHeaderView: View {
|
||||
private let nodesStore = NodesStore.shared
|
||||
private let nodeChannelStatus = MacNodeChannelStatusStore.shared
|
||||
private let dashboardManager = DashboardManager.shared
|
||||
private let onCheckForUpdates: (@MainActor () -> Void)?
|
||||
|
||||
init(state: AppState, isSleeping: Bool = false) {
|
||||
init(
|
||||
state: AppState,
|
||||
isSleeping: Bool = false,
|
||||
onCheckForUpdates: (@MainActor () -> Void)? = nil)
|
||||
{
|
||||
self._state = Bindable(wrappedValue: state)
|
||||
self.isSleeping = isSleeping
|
||||
self.onCheckForUpdates = onCheckForUpdates
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -76,8 +82,14 @@ struct StatusMenuHeaderView: View {
|
||||
let problems = self.problemLines
|
||||
if !problems.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(problems, id: \.label) { problem in
|
||||
self.statusLine(label: problem.label, diagnostic: problem.diagnostic, color: problem.color)
|
||||
ForEach(problems.indices, id: \.self) { index in
|
||||
let problem = problems[index]
|
||||
self.statusLine(
|
||||
label: problem.label,
|
||||
diagnostic: problem.diagnostic,
|
||||
color: problem.color,
|
||||
onCheckForUpdates: index == problems.count - 1 && self.shouldOfferAppUpdate
|
||||
? self.onCheckForUpdates : nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,23 +286,24 @@ struct StatusMenuHeaderView: View {
|
||||
return (String(localized: "Mac capabilities offline"), nil, .orange)
|
||||
}
|
||||
|
||||
private func statusLine(label: String, diagnostic: String? = nil, color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(diagnostic == nil ? 3 : 2)
|
||||
private var shouldOfferAppUpdate: Bool {
|
||||
guard self.state.connectionMode != .unconfigured,
|
||||
case .connected = self.controlChannel.state
|
||||
else { return false }
|
||||
return self.nodeChannelStatus.state.operatorStatusLine?.recoveryAction == .checkForUpdates
|
||||
}
|
||||
|
||||
if let diagnostic {
|
||||
Text(diagnostic)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(4)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.help(diagnostic.map { "\(label)\n\($0)" } ?? label)
|
||||
private func statusLine(
|
||||
label: String,
|
||||
diagnostic: String? = nil,
|
||||
color: Color,
|
||||
onCheckForUpdates: (@MainActor () -> Void)? = nil) -> some View
|
||||
{
|
||||
StatusMenuProblemLineView(
|
||||
label: label,
|
||||
diagnostic: diagnostic,
|
||||
color: color,
|
||||
onCheckForUpdates: onCheckForUpdates)
|
||||
}
|
||||
|
||||
private func pairingRow(_ label: String) -> some View {
|
||||
@@ -322,3 +335,35 @@ struct StatusMenuHeaderView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct StatusMenuProblemLineView: View {
|
||||
let label: String
|
||||
let diagnostic: String?
|
||||
let color: Color
|
||||
let onCheckForUpdates: (@MainActor () -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.color)
|
||||
.lineLimit(self.diagnostic == nil ? 3 : 2)
|
||||
|
||||
if let diagnostic = self.diagnostic {
|
||||
Text(diagnostic)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(4)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let onCheckForUpdates = self.onCheckForUpdates {
|
||||
Button(String(localized: "Update"), action: onCheckForUpdates)
|
||||
.font(.caption.weight(.semibold))
|
||||
.buttonStyle(.link)
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.help(self.diagnostic.map { "\(self.label)\n\($0)" } ?? self.label)
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,11 @@ final class StatusMenuRenderer: NSObject {
|
||||
switch content.kind {
|
||||
case .header:
|
||||
Self.configureHostedView(
|
||||
item, rootView: StatusMenuHeaderView(state: self.state, isSleeping: self.isSleeping))
|
||||
item,
|
||||
rootView: StatusMenuHeaderView(
|
||||
state: self.state,
|
||||
isSleeping: self.isSleeping,
|
||||
onCheckForUpdates: self.onInstallUpdate))
|
||||
case let .session(row):
|
||||
StatusMenuSessions.shared.configureSessionItem(item, row: row)
|
||||
case let .approval(request):
|
||||
|
||||
@@ -401,6 +401,7 @@ struct MacNodeModeCoordinatorTests {
|
||||
#expect(degraded?.label == "Mac node degraded — worker exited: schema mismatch")
|
||||
#expect(degraded?.diagnostic == nil)
|
||||
#expect(degraded?.isDegraded == true)
|
||||
#expect(degraded?.recoveryAction == nil)
|
||||
|
||||
let unavailable = MacNodeChannelState
|
||||
.unavailable(reason: "state database uses newer schema version 10\nTry: openclaw doctor")
|
||||
@@ -408,6 +409,25 @@ struct MacNodeModeCoordinatorTests {
|
||||
#expect(unavailable?.label == "Mac node unavailable — state database uses newer schema version 10")
|
||||
#expect(unavailable?.diagnostic == nil)
|
||||
#expect(unavailable?.isDegraded == false)
|
||||
#expect(unavailable?.recoveryAction == nil)
|
||||
}
|
||||
|
||||
@Test func `newer state schema exposes app update recovery from native node status`() throws {
|
||||
let diagnostic = "OpenClaw state database /state/state.db uses newer schema version 18; " +
|
||||
"this build supports 17."
|
||||
let line = try #require(MacNodeChannelState.connected(
|
||||
workerUnavailableReason: "worker exited with status exited(1)",
|
||||
diagnostic: diagnostic).operatorStatusLine)
|
||||
|
||||
#expect(line.label == "Mac node degraded — update the OpenClaw app: its worker supports schema 17, " +
|
||||
"but shared state requires schema 18")
|
||||
#expect(line.recoveryAction == .checkForUpdates)
|
||||
#expect(line.diagnostic == diagnostic)
|
||||
|
||||
let unrelated = try #require(MacNodeChannelState.connected(
|
||||
workerUnavailableReason: "worker exited with status exited(1)",
|
||||
diagnostic: "worker could not initialize").operatorStatusLine)
|
||||
#expect(unrelated.recoveryAction == nil)
|
||||
}
|
||||
|
||||
@Test func `worker stderr never becomes part of the operator status headline`() throws {
|
||||
@@ -423,6 +443,7 @@ struct MacNodeModeCoordinatorTests {
|
||||
#expect(line.label.contains(reason))
|
||||
#expect(!line.label.contains(diagnostic))
|
||||
#expect(line.diagnostic == diagnostic)
|
||||
#expect(line.recoveryAction == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct StatusMenuRecoveryPresentationTests {
|
||||
@Test func `worker recovery row renders guidance and only offers a callable update`() async throws {
|
||||
_ = AppKitTestSupport.application
|
||||
let diagnostic = "OpenClaw state database uses newer schema version 18; this build supports 17."
|
||||
let mismatch = MacNodeChannelState.connected(
|
||||
workerUnavailableReason: "worker exited with status exited(1)",
|
||||
diagnostic: diagnostic)
|
||||
let beforeLine: (
|
||||
label: String,
|
||||
diagnostic: String?,
|
||||
isDegraded: Bool,
|
||||
recoveryAction: MacNodeChannelState.RecoveryAction?) = (
|
||||
"Mac node degraded — worker exited with status exited(1)", diagnostic, true, nil)
|
||||
let afterLine = try #require(mismatch.operatorStatusLine)
|
||||
var updateCalls = 0
|
||||
|
||||
try await self.capture(beforeLine, name: "worker-schema-before", onCheckForUpdates: nil) { buttons in
|
||||
#expect(!buttons.contains("Update"))
|
||||
}
|
||||
try await self.capture(afterLine, name: "worker-schema-after", onCheckForUpdates: {
|
||||
updateCalls += 1
|
||||
}) { buttons in
|
||||
#expect(buttons.contains("Update"))
|
||||
}
|
||||
try await self.capture(afterLine, name: "worker-schema-no-updater", onCheckForUpdates: nil) { buttons in
|
||||
#expect(!buttons.contains("Update"))
|
||||
}
|
||||
#expect(updateCalls == 1)
|
||||
}
|
||||
|
||||
private func capture(
|
||||
_ line: (label: String, diagnostic: String?, isDegraded: Bool, recoveryAction: MacNodeChannelState.RecoveryAction?),
|
||||
name: String,
|
||||
onCheckForUpdates: (@MainActor () -> Void)?,
|
||||
verify: ([String]) throws -> Void) async throws
|
||||
{
|
||||
let view = StatusMenuProblemLineView(
|
||||
label: line.label,
|
||||
diagnostic: line.diagnostic,
|
||||
color: line.isDegraded ? .orange : .red,
|
||||
onCheckForUpdates: onCheckForUpdates)
|
||||
.padding(14)
|
||||
.frame(width: StatusMenuMetrics.width, alignment: .leading)
|
||||
.background(.regularMaterial)
|
||||
let hosting = NSHostingView(rootView: view)
|
||||
hosting.frame = NSRect(x: 0, y: 0, width: StatusMenuMetrics.width, height: 130)
|
||||
let window = NSWindow(contentRect: hosting.frame, styleMask: [.titled], backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.contentView = hosting
|
||||
defer {
|
||||
window.orderOut(nil)
|
||||
window.contentView = nil
|
||||
window.close()
|
||||
}
|
||||
window.orderFront(nil)
|
||||
hosting.layoutSubtreeIfNeeded()
|
||||
let elements = try await AppKitTestSupport.accessibilityElements(in: hosting)
|
||||
let controls = elements.filter {
|
||||
$0.accessibilityRole?() == .button || $0.accessibilityRole?() == .link
|
||||
}
|
||||
let directory = try #require(ProcessInfo.processInfo.environment["OPENCLAW_TEST_MENU_CAPTURE_DIR"])
|
||||
let image = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds))
|
||||
hosting.cacheDisplay(in: hosting.bounds, to: image)
|
||||
let png = try #require(image.representation(using: .png, properties: [:]))
|
||||
let output = URL(fileURLWithPath: directory, isDirectory: true)
|
||||
try png.write(to: output.appendingPathComponent("\(name)-window.png"))
|
||||
let status: [String: Any] = [
|
||||
"name": name,
|
||||
"source": "StatusMenuProblemLineView",
|
||||
"synthetic": true,
|
||||
"baselinePresentationReconstructed": name == "worker-schema-before",
|
||||
"updateActionAvailable": onCheckForUpdates != nil,
|
||||
"accessibilityElements": elements.map {
|
||||
[
|
||||
"role": String(describing: $0.accessibilityRole?()),
|
||||
"name": AppKitTestSupport.accessibilityName(of: $0) ?? "",
|
||||
]
|
||||
},
|
||||
"requiresVisualInspection": true,
|
||||
]
|
||||
try JSONSerialization.data(withJSONObject: status, options: [.sortedKeys])
|
||||
.write(to: output.appendingPathComponent("\(name)-capture-status.json"))
|
||||
|
||||
try verify(controls.compactMap(AppKitTestSupport.accessibilityName(of:)))
|
||||
if onCheckForUpdates != nil {
|
||||
let button = try #require(controls.first {
|
||||
AppKitTestSupport.accessibilityName(of: $0) == "Update"
|
||||
})
|
||||
#expect(button.accessibilityPerformPress?() == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,6 +330,9 @@ await runWithFailedTrailer("macos-native", async () => {
|
||||
"guest-model-invalidated",
|
||||
"guest-model-recovered",
|
||||
"history-message-recovery",
|
||||
"worker-schema-before",
|
||||
"worker-schema-after",
|
||||
"worker-schema-no-updater",
|
||||
]
|
||||
: ["thread-reasoning", "thread-tool-activity", "model-initial", "thread-restored"];
|
||||
const allowed = new RegExp(
|
||||
|
||||
Reference in new issue
Block a user