209 lines
6.8 KiB
Swift
209 lines
6.8 KiB
Swift
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<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)
|
|
|
|
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)
|
|
}
|