Files
Jarvis/App/MenuBar/StatusPanel.swift
james.maesandClaude Opus 4.7 cfd469f72b feat(round-4): probe severity model — ok/soft/loud/critical
Round 4 of boot-health work. The Toby-the-dog incident — Jarvis said "Got
it" while the embedder model was missing and the fact never persisted —
proved that Round 2's probe banner was too quiet. Probes were already
reporting `.degraded` correctly; what was missing was a way for the App
layer to route a critical failure differently from a soft one (e.g.,
non-dismissible banner for memory off vs. silent Status row for camera
TCC not yet requested).

This slice introduces a severity axis without changing behavior:

- `FailureSeverity { critical, loud, soft }` on `ProbeStatus`'s non-ok
  cases (degraded/failed/unknown). `.ok` carries no severity.
- `ProbeStatus.severity` accessor (nil for `.ok`).
- `BootHealthSnapshot.overallHealth: OverallHealth { ok, soft, loud,
  critical }` — worst severity wins; `.ok` only if every subsystem ok.
- Each concrete probe annotates its failure modes:
  * Memory: failed → .critical (agent grounding; the Toby case)
  * Anthropic: missing/empty key → .critical (agent can't run)
  * Ollama: missing models → .loud (fact extraction broken, chat fine)
  * Voice: mic denied → .loud; dormant install → .soft
  * Vision: TCC denied → .soft (vision is optional)
  * MCP: 0 tools → .critical; <minimum → .loud
  * Replay: init failed → .critical; missing file → .loud
  * Webview: mismatched/timedOut → .critical; sentHello → .soft
- DormantSubsystemProbe gains `severity` parameter (default .soft);
  AppDelegate wires memory-dormant as .critical.

No behavioral change yet — banners still treat all failures equally;
the agent system prompt is unchanged. Slices 2 + 3 land those.

Tests: 13/13 in BootHealthOrchestratorTests pass, including 4 new tests
for overallHealth + severity accessor + JSON roundtrip with severity.
2 pre-existing failures in LLMEventTests/OllamaRequestBodyTests are
unrelated (qwen model-ID literal drift from commit 8d0a959, present
before this slice — confirmed via git stash).

Boundary gates 18/18 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:24:43 -05:00

272 lines
9.2 KiB
Swift

import AppKit
import SwiftUI
import AgentCore
// Round 3 — Status menu dialog per the 2026-05-11 handoff.
//
// `NSPanel` + SwiftUI body that renders the current `BootHealthSnapshot`,
// re-probes on every open ("NOT FAKED 0 100% real only" — never show a
// stale cached snapshot), and exposes "Re-probe all" + "Copy report"
// buttons. AppKit, not WKWebView: this is an operator/dev tool, not a
// user-facing surface, and shouldn't ride the R3F render pipeline.
//
// Non-modal — uses `makeKeyAndOrderFront(_:)`, not `.runModal()`. Project
// rule `scripts/check-no-modal-presentation.sh` is the gate that keeps it
// that way.
// MARK: - StatusPanelModel
/// View-model wrapping the `BootHealthOrchestrator`. Exposes a published
/// `snapshot` for SwiftUI, an `isProbing` flag for the spinner, and a
/// `reprobe()` async method that AppDelegate's `runBootHealth()` drives
/// (so the snapshot read in the panel matches the snapshot logged to
/// the system log — single source of truth).
@MainActor
public final class StatusPanelModel: ObservableObject {
@Published public var snapshot: BootHealthSnapshot?
@Published public var isProbing: Bool = false
@Published public var copyConfirmation: String?
private let orchestrator: BootHealthOrchestrator
/// Closure that runs the same registration + logging + banner-enqueue
/// sweep AppDelegate ran at boot. Injected so the panel's "Re-probe"
/// button hits the production code path, not a parallel one.
private let probeRunner: @MainActor () async -> Void
public init(
orchestrator: BootHealthOrchestrator,
probeRunner: @escaping @MainActor () async -> Void
) {
self.orchestrator = orchestrator
self.probeRunner = probeRunner
}
/// Run a fresh sweep, then read the new snapshot off the orchestrator.
public func reprobe() async {
isProbing = true
copyConfirmation = nil
await probeRunner()
snapshot = await orchestrator.lastSnapshot()
isProbing = false
}
/// Encode the snapshot as pretty-printed JSON and write to the
/// general pasteboard. Updates `copyConfirmation` for the caption
/// next to the button — auto-clears on next re-probe.
public func copyReport() {
guard let snap = snapshot else { return }
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
guard let data = try? encoder.encode(snap),
let s = String(data: data, encoding: .utf8) else {
copyConfirmation = "Encode failed"
return
}
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(s, forType: .string)
copyConfirmation = "Copied \(data.count) B"
}
}
// MARK: - SwiftUI view
struct StatusPanelView: View {
@ObservedObject var model: StatusPanelModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Jarvis Status").font(.title2).bold()
Spacer()
if model.isProbing {
ProgressView().controlSize(.small)
}
}
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 6) {
if let snap = model.snapshot {
ForEach(snap.subsystems, id: \.name) { health in
SubsystemRow(health: health)
}
Text("Probed \(snap.subsystems.count) subsystems at \(snap.producedAt.formatted(date: .omitted, time: .standard))")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 6)
} else if model.isProbing {
Text("Probing…")
.foregroundStyle(.secondary)
} else {
Text("Probes haven't run yet.")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: .infinity)
Divider()
HStack(spacing: 12) {
Button("Re-probe all") {
Task { await model.reprobe() }
}
.disabled(model.isProbing)
.keyboardShortcut("r", modifiers: [.command])
Spacer()
if let conf = model.copyConfirmation {
Text(conf)
.font(.caption)
.foregroundStyle(.secondary)
.transition(.opacity)
}
Button("Copy report") {
model.copyReport()
}
.disabled(model.snapshot == nil)
.keyboardShortcut("c", modifiers: [.command, .shift])
}
}
.padding()
.frame(minWidth: 480, minHeight: 380)
.task {
// Always re-probe on open. The handoff's mandate is live
// state, never a cached boot snapshot.
await model.reprobe()
}
}
}
// MARK: - Row
private struct SubsystemRow: View {
let health: SubsystemHealth
var body: some View {
HStack(alignment: .top, spacing: 10) {
Text(glyph)
.font(.body.bold())
.foregroundColor(color)
.frame(width: 18, alignment: .center)
.accessibilityLabel(statusLabel)
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(health.name)
.font(.body.weight(.medium))
Text(statusLabel)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text("\(health.latencyMs) ms")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Text(health.evidence)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let reason = reasonText {
Text(reason)
.font(.caption)
.foregroundColor(reasonColor)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.padding(.vertical, 4)
}
private var glyph: String {
switch health.status {
case .ok: return "✓"
case .degraded: return "⚠"
case .failed: return "✗"
case .unknown: return "?"
}
}
private var color: Color {
switch health.status {
case .ok: return .green
case .degraded: return .orange
case .failed: return .red
case .unknown: return .gray
}
}
private var statusLabel: String {
switch health.status {
case .ok: return "ok"
case .degraded: return "degraded"
case .failed: return "failed"
case .unknown: return "unknown"
}
}
/// The reason string is only meaningful for non-ok statuses; for `.ok`
/// the evidence line is already the concrete proof of health.
private var reasonText: String? {
switch health.status {
case .ok: return nil
case .degraded(let r, _), .failed(let r, _), .unknown(let r, _): return r
}
}
private var reasonColor: Color {
switch health.status {
case .failed: return .red
case .degraded: return .orange
default: return .secondary
}
}
}
// MARK: - NSPanel
/// Floating panel that owns the SwiftUI hosting view. Constructed once
/// on first `Status…` menu click and kept alive across closes via
/// `isReleasedWhenClosed = false` (default NSPanel behavior releases the
/// window on close → second open crashes).
@MainActor
public final class StatusPanel: NSPanel {
public let model: StatusPanelModel
private let hosting: NSHostingView<StatusPanelView>
public init(model: StatusPanelModel) {
self.model = model
self.hosting = NSHostingView(rootView: StatusPanelView(model: model))
super.init(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 380),
styleMask: [.titled, .closable, .resizable, .nonactivatingPanel],
backing: .buffered,
defer: false
)
title = "Jarvis Status"
isFloatingPanel = false
becomesKeyOnlyIfNeeded = false
isReleasedWhenClosed = false
hidesOnDeactivate = false
hosting.autoresizingMask = [.width, .height]
contentView = hosting
}
/// Bring panel onto screen + give it focus. Non-modal — caller
/// continues running. SwiftUI's `.task` modifier on the view body
/// fires a re-probe automatically on the appearance pass.
public func present() {
if !isVisible {
center()
}
makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
}