diff --git a/apps/macos/Sources/Clawdis/HealthStore.swift b/apps/macos/Sources/Clawdis/HealthStore.swift index 2f77ad2e7..a19deaa26 100644 --- a/apps/macos/Sources/Clawdis/HealthStore.swift +++ b/apps/macos/Sources/Clawdis/HealthStore.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import OSLog struct HealthSnapshot: Codable, Sendable { struct Web: Codable, Sendable { @@ -60,6 +61,8 @@ enum HealthState: Equatable { final class HealthStore: ObservableObject { static let shared = HealthStore() + private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "health") + @Published private(set) var snapshot: HealthSnapshot? @Published private(set) var lastSuccess: Date? @Published private(set) var lastError: String? @@ -108,18 +111,23 @@ final class HealthStore: ObservableObject { env: env, timeout: 15) - // Always try to decode JSON even when the CLI exits non-zero; it prints the - // failure snapshot before exiting so we can surface a useful message. - if let data = response.payload, !data.isEmpty, - let decoded = try? JSONDecoder().decode(HealthSnapshot.self, from: data) - { - self.snapshot = decoded - if response.ok { - self.lastSuccess = Date() - self.lastError = nil - } else { - self.lastError = self.describeFailure(from: decoded, fallback: response.message) + if let data = response.payload, !data.isEmpty { + if let decoded = decodeHealthSnapshot(from: data) { + self.snapshot = decoded + if response.ok { + self.lastSuccess = Date() + self.lastError = nil + } else { + self.lastError = self.describeFailure(from: decoded, fallback: response.message) + } + return } + + let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let snippet = String(text.prefix(220)) + Self.logger.error("health decode failed; payload=\(snippet, privacy: .public)") + self.lastError = snippet.isEmpty ? (response.message ?? "health probe failed") : "health output not JSON: \(snippet)" + if onDemand { self.snapshot = nil } return } @@ -183,3 +191,18 @@ func msToAge(_ ms: Double) -> String { let days = Int(round(Double(hours) / 24)) return "\(days)d" } + +/// Decode a health snapshot, tolerating stray log lines before/after the JSON blob. +func decodeHealthSnapshot(from data: Data) -> HealthSnapshot? { + let decoder = JSONDecoder() + if let snap = try? decoder.decode(HealthSnapshot.self, from: data) { + return snap + } + guard let text = String(data: data, encoding: .utf8) else { return nil } + guard let firstBrace = text.firstIndex(of: "{"), let lastBrace = text.lastIndex(of: "}") else { + return nil + } + let slice = text[firstBrace...lastBrace] + let cleaned = Data(slice.utf8) + return try? decoder.decode(HealthSnapshot.self, from: cleaned) +} diff --git a/apps/macos/Tests/ClawdisIPCTests/HealthDecodeTests.swift b/apps/macos/Tests/ClawdisIPCTests/HealthDecodeTests.swift new file mode 100644 index 000000000..596de4731 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/HealthDecodeTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing +@testable import Clawdis + +@Suite struct HealthDecodeTests { + private let sampleJSON: String = { + // minimal but complete payload + """ + {"ts":1733622000,"durationMs":420,"web":{"linked":true,"authAgeMs":120000,"connect":{"ok":true,"status":200,"error":null,"elapsedMs":800}},"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]},"ipc":{"path":"/tmp/ipc.sock","exists":true}} + """ + }() + + @Test func decodesCleanJSON() async throws { + let data = Data(sampleJSON.utf8) + let snap = decodeHealthSnapshot(from: data) + + #expect(snap?.web.linked == true) + #expect(snap?.sessions.count == 1) + #expect(snap?.ipc.exists == true) + } + + @Test func decodesWithLeadingNoise() async throws { + let noisy = "debug: something logged\n" + sampleJSON + "\ntrailer" + let snap = decodeHealthSnapshot(from: Data(noisy.utf8)) + + #expect(snap?.web.connect?.status == 200) + } + + @Test func failsWithoutBraces() async throws { + let data = Data("no json here".utf8) + let snap = decodeHealthSnapshot(from: data) + + #expect(snap == nil) + } +}