From cb5c93244749030105cd15d1efa6f479e933707d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Dec 2025 04:38:20 +0000 Subject: [PATCH] Health: CLI probe and mac UI surfacing --- .../Sources/Clawdis/GeneralSettings.swift | 72 +++++++++ apps/macos/Sources/Clawdis/HealthStore.swift | 149 ++++++++++++++++++ apps/macos/Sources/Clawdis/MenuBar.swift | 16 ++ docs/health.md | 4 +- docs/mac/health.md | 14 +- src/cli/program.ts | 1 - src/web/auto-reply.ts | 4 +- src/web/login.coverage.test.ts | 6 +- src/web/login.test.ts | 2 +- 9 files changed, 252 insertions(+), 16 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/HealthStore.swift diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index a88f52258..8c8ac2115 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -3,6 +3,7 @@ import SwiftUI struct GeneralSettings: View { @ObservedObject var state: AppState + @ObservedObject private var healthStore = HealthStore.shared @State private var isInstallingCLI = false @State private var cliStatus: String? @State private var cliInstalled = false @@ -55,6 +56,12 @@ struct GeneralSettings: View { } } + VStack(alignment: .leading, spacing: 8) { + Text("Health") + .font(.callout.weight(.semibold)) + self.healthCard + } + VStack(alignment: .leading, spacing: 6) { Text("CLI helper") .font(.callout.weight(.semibold)) @@ -143,4 +150,69 @@ struct GeneralSettings: View { self.cliInstallLocation = installLocation self.cliInstalled = installLocation != nil } + + private var healthCard: some View { + let snapshot = self.healthStore.snapshot + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle() + .fill(self.healthStore.state.tint) + .frame(width: 10, height: 10) + Text(self.healthStore.summaryLine) + .font(.callout.weight(.semibold)) + } + + if let snap = snapshot { + Text("Linked auth age: \(healthAgeString(snap.web.authAgeMs))") + .font(.caption) + .foregroundStyle(.secondary) + Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)") + .font(.caption) + .foregroundStyle(.secondary) + if let recent = snap.sessions.recent.first { + Text("Last activity: \(recent.key) \(recent.updatedAt != nil ? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000)) : "unknown")") + .font(.caption) + .foregroundStyle(.secondary) + } + Text("Last check: \(relativeAge(from: self.healthStore.lastSuccess))") + .font(.caption) + .foregroundStyle(.secondary) + } else if let error = self.healthStore.lastError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } else { + Text("Health check pending…") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 10) { + Button { + Task { await self.healthStore.refresh(onDemand: true) } + } label: { + if self.healthStore.isRefreshing { + ProgressView().controlSize(.small) + } else { + Label("Run Health Check", systemImage: "arrow.clockwise") + } + } + .disabled(self.healthStore.isRefreshing) + + Button { + NSWorkspace.shared.selectFile("/tmp/clawdis/clawdis.log", inFileViewerRootedAtPath: "/tmp/clawdis/") + } label: { + Label("Reveal Logs", systemImage: "doc.text.magnifyingglass") + } + } + } + .padding(12) + .background(Color.gray.opacity(0.08)) + .cornerRadius(10) + } +} + +private func healthAgeString(_ ms: Double?) -> String { + guard let ms else { return "unknown" } + return msToAge(ms) } diff --git a/apps/macos/Sources/Clawdis/HealthStore.swift b/apps/macos/Sources/Clawdis/HealthStore.swift new file mode 100644 index 000000000..f24d887b6 --- /dev/null +++ b/apps/macos/Sources/Clawdis/HealthStore.swift @@ -0,0 +1,149 @@ +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? + 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 } + + let response = await ShellRunner.run( + command: ["clawdis", "health", "--json"], + cwd: nil, + env: nil, + timeout: 15) + + guard response.ok, let data = response.payload, !data.isEmpty else { + self.lastError = response.message ?? "health probe failed" + if onDemand { self.snapshot = nil } + return + } + + do { + let decoded = try JSONDecoder().decode(HealthSnapshot.self, from: data) + self.snapshot = decoded + self.lastSuccess = Date() + self.lastError = nil + } catch { + self.lastError = error.localizedDescription + 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 { + 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" + } +} + +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" +} diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index dd43c0834..3615317d5 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -54,12 +54,14 @@ struct ClawdisApp: App { private struct MenuContent: View { @ObservedObject var state: AppState @ObservedObject private var relayManager = RelayProcessManager.shared + @ObservedObject private var healthStore = HealthStore.shared @Environment(\.openSettings) private var openSettings var body: some View { VStack(alignment: .leading, spacing: 8) { Toggle(isOn: self.activeBinding) { Text("Clawdis Active") } self.relayStatusRow + self.healthStatusRow Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") } .disabled(!voiceWakeSupported) .opacity(voiceWakeSupported ? 1 : 0.5) @@ -68,6 +70,7 @@ private struct MenuContent: View { Button("Settings…") { self.open(tab: .general) } .keyboardShortcut(",", modifiers: [.command]) Button("About Clawdis") { self.open(tab: .about) } + Button("Run Health Check") { Task { await self.healthStore.refresh(onDemand: true) } } Divider() Button("Quit") { NSApplication.shared.terminate(nil) } } @@ -93,6 +96,19 @@ private struct MenuContent: View { .padding(.vertical, 4) } + private var healthStatusRow: some View { + let state = self.healthStore.state + return HStack(spacing: 8) { + Circle() + .fill(state.tint) + .frame(width: 8, height: 8) + Text(self.healthStore.summaryLine) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + } + .padding(.vertical, 2) + } + private func statusColor(_ status: RelayProcessManager.Status) -> Color { switch status { case .running: .green diff --git a/docs/health.md b/docs/health.md index 935892580..78486e9f8 100644 --- a/docs/health.md +++ b/docs/health.md @@ -19,5 +19,5 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. - Repeated reconnect exits → tune `web.reconnect` (flags: `--web-retries`, `--web-retry-initial`, `--web-retry-max`) and rerun relay. - No inbound messages → confirm linked phone is online and sender is allowed; use `pnpm clawdis heartbeat --all --verbose` to test each known recipient. -## Planned "health" command -A dedicated `clawdis health --json` probe (connect-only, no sends) is planned to report: linked creds, auth age, Baileys connect result/status code, session-store summary, and IPC presence. Until it lands, use the checks above. +## Dedicated "health" command +`pnpm clawdis health --json` runs a connect-only probe (no sends) and reports: linked creds, auth age, Baileys connect result/status code, session-store summary, IPC presence, and a probe duration. It exits non-zero if not linked or if the connect fails/timeouts. Use `--timeout ` to override the 10s default. diff --git a/docs/mac/health.md b/docs/mac/health.md index c50fcce1d..faff5e2de 100644 --- a/docs/mac/health.md +++ b/docs/mac/health.md @@ -2,21 +2,21 @@ How to see whether the WhatsApp Web/Baileys bridge is healthy from the menu bar app. -## Menu bar (planned) -- Status dot expands beyond “relay running” to reflect Baileys health: +## Menu bar +- Status dot now reflects Baileys health: - Green: linked + socket opened recently. - Orange: connecting/retrying. - Red: logged out or probe failed. - Secondary line reads "Web: linked · auth 12m · socket ok" or shows the failure reason. - "Run Health Check" menu item triggers an on-demand probe. -## Settings (planned) -- General tab gains a Health card showing: linked E.164, auth age, session-store path/count, last check time, last error/status code, and buttons for Run Health Check / Reveal Logs / Relink. +## Settings +- General tab gains a Health card showing: linked auth age, session-store path/count, last check time, last error/status code, and buttons for Run Health Check / Reveal Logs. - Uses a cached snapshot so the UI loads instantly and falls back gracefully when offline. -## How the probe works (planned) +## How the probe works - App runs `clawdis health --json` via `ShellRunner` every ~60s and on demand. The probe loads creds, attempts a short Baileys connect, and reports status without sending messages. - Cache the last good snapshot and the last error separately to avoid flicker; show the timestamp of each. -## Until the UI ships -- Use the CLI flow in `docs/health.md` (status, heartbeat dry-run, relay heartbeat) and tail `/tmp/clawdis/clawdis.log` for `web-heartbeat` / `web-reconnect`. +## When in doubt +- You can still use the CLI flow in `docs/health.md` (status, heartbeat dry-run, relay heartbeat) and tail `/tmp/clawdis/clawdis.log` for `web-heartbeat` / `web-reconnect`. diff --git a/src/cli/program.ts b/src/cli/program.ts index cac2a0a29..ec867fb96 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -19,7 +19,6 @@ import { import { defaultRuntime } from "../runtime.js"; import { VERSION } from "../version.js"; import { - DEFAULT_HEARTBEAT_SECONDS, resolveHeartbeatSeconds, resolveReconnectPolicy, } from "../web/reconnect.js"; diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index a232971d5..738190360 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -214,8 +214,8 @@ export async function runWebHeartbeatOnce(opts: { const cfg = cfgOverride ?? loadConfig(); const sessionCfg = cfg.inbound?.reply?.session; - const mainKey = sessionCfg?.mainKey ?? "main"; const sessionScope = sessionCfg?.scope ?? "per-sender"; + const mainKey = sessionCfg?.mainKey; const sessionKey = resolveSessionKey(sessionScope, { From: to }, mainKey); if (sessionId) { const storePath = resolveStorePath(cfg.inbound?.reply?.session?.store); @@ -439,7 +439,7 @@ function getSessionSnapshot( const key = resolveSessionKey( scope, { From: from, To: "", Body: "" }, - sessionCfg?.mainKey ?? "main", + sessionCfg?.mainKey, ); const store = loadSessionStore(resolveStorePath(sessionCfg?.store)); const entry = store[key]; diff --git a/src/web/login.coverage.test.ts b/src/web/login.coverage.test.ts index 2825f18b5..d872f8343 100644 --- a/src/web/login.coverage.test.ts +++ b/src/web/login.coverage.test.ts @@ -40,7 +40,7 @@ describe("loginWeb coverage", () => { .mockResolvedValueOnce(undefined); const runtime = { log: vi.fn(), error: vi.fn() } as never; - await loginWeb(false, waitForWaConnection as never, runtime); + await loginWeb(false, "web", waitForWaConnection as never, runtime); expect(createWaSocket).toHaveBeenCalledTimes(2); const firstSock = await createWaSocket.mock.results[0].value; @@ -55,7 +55,7 @@ describe("loginWeb coverage", () => { output: { statusCode: DisconnectReason.loggedOut }, }); - await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow( + await expect(loginWeb(false, "web", waitForWaConnection as never)).rejects.toThrow( /cache cleared/i, ); expect(rmMock).toHaveBeenCalledWith("/tmp/wa-creds", { @@ -66,7 +66,7 @@ describe("loginWeb coverage", () => { it("formats and rethrows generic errors", async () => { waitForWaConnection.mockRejectedValueOnce(new Error("boom")); - await expect(loginWeb(false, waitForWaConnection as never)).rejects.toThrow( + await expect(loginWeb(false, "web", waitForWaConnection as never)).rejects.toThrow( "formatted:Error: boom", ); expect(formatError).toHaveBeenCalled(); diff --git a/src/web/login.test.ts b/src/web/login.test.ts index 76c37aa0b..ff8908495 100644 --- a/src/web/login.test.ts +++ b/src/web/login.test.ts @@ -38,7 +38,7 @@ describe("web login", () => { const waiter: typeof waitForWaConnection = vi .fn() .mockResolvedValue(undefined); - await loginWeb(false, waiter); + await loginWeb(false, "web", waiter); await new Promise((resolve) => setTimeout(resolve, 550)); expect(sock.ws.close).toHaveBeenCalled(); });