import Foundation import SwiftUI import OSLog struct HealthSnapshot: Codable, Sendable { struct Web: Codable, Sendable { struct Connect: Codable, Sendable { let ok: Bool let status: Int? let error: String? let elapsedMs: Double? } let linked: Bool let authAgeMs: Double? let connect: Connect? } struct SessionInfo: Codable, Sendable { let key: String let updatedAt: Double? let age: Double? } struct Sessions: Codable, Sendable { let path: String let count: Int let recent: [SessionInfo] } struct IPC: Codable, Sendable { let path: String let exists: Bool } let ts: Double let durationMs: Double let web: Web let heartbeatSeconds: Int? let sessions: Sessions let ipc: IPC } enum HealthState: Equatable { case unknown case ok case linkingNeeded case degraded(String) var tint: Color { switch self { case .ok: .green case .linkingNeeded: .red case .degraded: .orange case .unknown: .secondary } } } @MainActor 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? @Published private(set) var isRefreshing = false private var loopTask: Task? private let refreshInterval: TimeInterval = 60 private init() { self.start() } func start() { guard self.loopTask == nil else { return } self.loopTask = Task { [weak self] in guard let self else { return } while !Task.isCancelled { await self.refresh() try? await Task.sleep(nanoseconds: UInt64(self.refreshInterval * 1_000_000_000)) } } } func stop() { self.loopTask?.cancel() self.loopTask = nil } func refresh(onDemand: Bool = false) async { guard !self.isRefreshing else { return } self.isRefreshing = true defer { self.isRefreshing = false } guard CommandResolver.hasAnyClawdisInvoker() else { self.lastError = "clawdis CLI not found; install deps in the configured project root or add it to PATH" if onDemand { self.snapshot = nil } return } var env = ProcessInfo.processInfo.environment env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") let response = await ShellRunner.run( command: CommandResolver.clawdisCommand(subcommand: "health", extraArgs: ["--json"]), cwd: CommandResolver.projectRootPath(), env: env, timeout: 15) 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 } self.lastError = response.message ?? "health probe failed" if onDemand { self.snapshot = nil } } var state: HealthState { guard let snap = self.snapshot else { return .unknown } if !snap.web.linked { return .linkingNeeded } if let connect = snap.web.connect, !connect.ok { let reason = connect.error ?? "connect failed" return .degraded(reason) } return .ok } var summaryLine: String { if self.isRefreshing { return "Health check running…" } if let error = self.lastError { return "Health check failed: \(error)" } guard let snap = self.snapshot else { return "Health check pending" } if !snap.web.linked { return "Not linked — run clawdis login" } let auth = snap.web.authAgeMs.map { msToAge($0) } ?? "unknown" if let connect = snap.web.connect, !connect.ok { let code = connect.status.map(String.init) ?? "?" return "Link stale? status \(code)" } return "linked · auth \(auth) · socket ok" } private func describeFailure(from snap: HealthSnapshot, fallback: String?) -> String { if !snap.web.linked { return "Not linked — run clawdis login" } if let connect = snap.web.connect, !connect.ok { if let err = connect.error, err.contains("timeout") { let elapsed = connect.elapsedMs.map { " after \(Int($0))ms" } ?? "" return "Web connect timed out\(elapsed)" } let code = connect.status.map { "status \($0)" } ?? "status unknown" let elapsed = connect.elapsedMs.map { "\(Int($0))ms" } ?? "unknown duration" let reason = connect.error ?? "connect failed" return "\(reason) (\(code), \(elapsed))" } if !snap.ipc.exists { return "IPC socket missing at \(snap.ipc.path)" } if let fallback, !fallback.isEmpty { return fallback } return "health probe failed" } } func msToAge(_ ms: Double) -> String { let minutes = Int(round(ms / 60000)) if minutes < 1 { return "just now" } if minutes < 60 { return "\(minutes)m" } let hours = Int(round(Double(minutes) / 60)) if hours < 48 { return "\(hours)h" } 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) }