Files
clawdbot/apps/macos/Sources/Clawdis/HealthStore.swift
2025-12-07 21:37:06 +00:00

182 lines
5.5 KiB
Swift

import Foundation
import SwiftUI
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()
@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<Void, Never>?
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)
// 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)
}
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 {
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"
}