Files
Jarvis/App/Boot/BootHealthProbes.swift
james.maes 4b1dd6404b fix(voice): v0.1 CRIT cluster — wake / STT / TTS / boot probes (#28, #29, #30, #31, #32, #33, #34, #36, #158)
#28 (CRIT): setCancelInFlight calls stopFeed() (preserves stream); rebuild consumer re-arms DAG against new ring. Wake-word no longer dies on audio-graph rebuild.

#29 (CRIT): sttFactory reads PerTurnSnapshot.stt.whisperKitFallback; get_self_state returns canonical snake_case backend constants. Factory signature: () -> any STTProvider → () async -> any STTProvider (existing test sites compile unchanged under Swift 6 implicit-async-closure rules).

#30 (CRIT): TTSEngineActor.synthesize emits .ttsStopped after .finished on natural completion (two-emit-site model: barge-in + natural-completion).

#31 (CRIT): VoiceTTSAdapter.tierResolver wired to read snap.tts.tier from configStore.

#32 (HIGH): Rolling-window trim in OpenWakeWordSession.runORT — mel ≤ 84, embeddings ≤ 16, nextEmbStart slides on drop.

#33 (HIGH): three new boot probes — WakeWordBootHealthProbe, STTBootHealthProbe, TTSBootHealthProbe. New WakeWordDAG.isFeedArmed + VoiceTTSAdapter.hasEngine accessors.

#34 (HIGH): vad.reset() at top of startSTTSession clears Silero VAD LSTM hidden state + wasSpeech between sessions.

#36 (HIGH): new Broadcaster.Priority.agentHud (protects stateChange/turnEnd/error); installAgent adds subscriber pumping AgentHudIntent into dormantAgentContinuation.

Voice 90 + AgentCore 242 tests pass; all 18 boundary gates green.
2026-05-13 06:31:17 -05:00

666 lines
27 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import AppKit
import AVFoundation
import Speech
import AgentCore
import Bus
import Config
import Keychain
import JarvisMCP
import JarvisVision
import Memory
import Voice
// Round 2 — concrete probes per the 2026-05-11 handoff.
//
// Each probe is a small Sendable struct that wraps a live read against the
// real subsystem (DB query, /api/tags call, TCC status read, helper-count
// query) and translates the result to a `ProbeOutcome`. No probe reads a
// cached install flag — every status is the result of a fresh round-trip.
//
// All probes are constructed at install time (after the seven install Tasks
// join in AppDelegate) so each probe has a strong reference to the live
// actor / adapter it needs. nil-handling lives in the AppDelegate wire-up:
// a subsystem that never installed registers a no-op probe that returns
// `.unknown(reason: "subsystem dormant")` rather than skipping registration.
// MARK: - Memory
/// Runs `MemoryStatsStoreAdapter.getMemoryStats()` end-to-end. Verifies the
/// schema is queryable, vec0 is loaded, and the DB file is open. Failure
/// here means memory writes silently disappear — the subsystem is broken
/// at its lowest layer.
public struct MemoryBootHealthProbe: BootHealthProbe {
public let name = "memory"
private let adapter: MemoryStatsStoreAdapter
public init(adapter: MemoryStatsStoreAdapter) {
self.adapter = adapter
}
public func probe() async -> ProbeOutcome {
do {
let stats = try await adapter.getMemoryStats()
let evidence = "\(stats.turnsCount) turns, \(stats.factsActive)/\(stats.factsTotal) facts, vec=\(stats.vecVersion), sqlite=\(stats.sqliteVersion)"
return ProbeOutcome(status: .ok, evidence: evidence)
} catch {
// Round 4 — memory IS the agent's grounding. Failure here means
// facts vanish silently and the agent can lie about remembering
// (the Toby-the-dog scenario).
return ProbeOutcome(
status: .failed(reason: String(describing: error), severity: .critical),
evidence: "getMemoryStats threw"
)
}
}
}
// MARK: - Anthropic
/// Structural check: API key present in Keychain and non-empty. We do NOT
/// fire a billable network probe at boot — instead the Round 3 Status panel
/// offers an explicit "Re-probe" button so the user pays for verification
/// only when they ask. Evidence is honest about what was verified.
///
/// NOT FAKED: returns `.ok` only when a non-empty key is in the keychain
/// at the configured slot. `.unknown(reason:)` on absence — never `.ok`.
public struct AnthropicBootHealthProbe: BootHealthProbe {
public let name = "anthropic"
private let keychain: any KeychainStore
public init(keychain: any KeychainStore) {
self.keychain = keychain
}
public func probe() async -> ProbeOutcome {
do {
let key = try keychain.get(.anthropic)
guard !key.isEmpty else {
// Round 4 — empty key is a critical block on the agent (the
// Anthropic provider can't authenticate).
return ProbeOutcome(
status: .unknown(reason: "API key slot present but empty", severity: .critical),
evidence: "key length 0"
)
}
let prefix = String(key.prefix(7))
let evidence = "key present (\(prefix)…, len=\(key.count)) — network probe deferred to Status menu"
return ProbeOutcome(status: .ok, evidence: evidence)
} catch let error as KeychainError {
switch error {
case .itemNotFound:
// Round 4 — missing key is critical (agent can't run).
return ProbeOutcome(
status: .unknown(reason: "no API key in Keychain", severity: .critical),
evidence: "keychain.itemNotFound"
)
default:
return ProbeOutcome(
status: .failed(reason: "keychain read failed: \(error)", severity: .critical),
evidence: "keychain error"
)
}
} catch {
return ProbeOutcome(
status: .failed(reason: String(describing: error), severity: .critical),
evidence: "keychain.get threw unexpected error"
)
}
}
}
// MARK: - Ollama
/// Live `GET /api/tags` against the configured Ollama base URL with a 3-second
/// timeout. Compares the returned model list against the three names the app
/// actually depends on (extractor model, agent-loop fallback, embedder).
///
/// 3-second timeout caps boot delay if Ollama is offline. Probes run in
/// parallel, so this doesn't serialize with the others.
public struct OllamaBootHealthProbe: BootHealthProbe {
public let name = "ollama"
private let baseURL: URL
private let requiredModels: [String]
private let session: URLSession
public init(
baseURL: URL,
requiredModels: [String] = [
ModelID.qwen36.rawValue,
ModelID.qwen25coder32b.rawValue,
"nomic-embed-text"
],
sessionConfiguration: URLSessionConfiguration = .ephemeral
) {
self.baseURL = baseURL
self.requiredModels = requiredModels
let config = sessionConfiguration
config.timeoutIntervalForRequest = 3.0
config.timeoutIntervalForResource = 3.0
self.session = URLSession(configuration: config)
}
public func probe() async -> ProbeOutcome {
let url = baseURL.appendingPathComponent("api/tags")
var request = URLRequest(url: url)
request.timeoutInterval = 3.0
request.httpMethod = "GET"
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
// Round 4 — Ollama unreachable means chat fallback + memory
// extraction + embeddings are all dead. Loud, not critical:
// Anthropic path can still work for primary chat.
return ProbeOutcome(
status: .failed(reason: "Ollama /api/tags HTTP \(code)", severity: .loud),
evidence: "non-200 from \(url.absoluteString)"
)
}
// Tag-agnostic match — Ollama returns names with `:latest` /
// `:32b` etc. suffixes. A required model `"nomic-embed-text"`
// matches against `"nomic-embed-text:latest"` because the user
// pulled the default tag; explicit `"qwen3.6:latest"` matches
// only that exact tag. Strip ":<tag>" from EACH side only when
// the required name has no tag itself.
let tagged = parseModelNames(from: data)
let missing = requiredModels.filter { required in
if required.contains(":") {
return !tagged.contains(required)
}
return !tagged.contains(where: { $0 == required || $0.hasPrefix(required + ":") })
}
if missing.isEmpty {
return ProbeOutcome(
status: .ok,
evidence: "all \(requiredModels.count) required models present: \(requiredModels.joined(separator: ", "))"
)
}
// Round 4 — missing models = the Toby case. Embedding step
// fails, no row inserted in `facts`, agent doesn't know it
// can't remember. Loud red banner so the user sees it.
return ProbeOutcome(
status: .degraded(reason: "missing models: \(missing.joined(separator: ", "))", severity: .loud),
evidence: "have \(tagged.count) models; missing \(missing.count)"
)
} catch {
return ProbeOutcome(
status: .failed(reason: "Ollama unreachable: \(error.localizedDescription)", severity: .loud),
evidence: "\(baseURL.absoluteString) — \(error.localizedDescription)"
)
}
}
/// Parses the `models` array from `/api/tags` JSON. Tolerant of
/// surrounding fields and missing keys — returns an empty array if the
/// shape doesn't match.
private func parseModelNames(from data: Data) -> Set<String> {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let models = json["models"] as? [[String: Any]] else {
return []
}
return Set(models.compactMap { $0["name"] as? String })
}
}
// MARK: - Voice
/// Microphone TCC status + audio-graph open status. Voice can be:
/// - .ok when graph is open and mic is authorized
/// - .degraded when graph open but mic denied (rare — graph open implies authorized)
/// - .failed when graph never opened
/// - .unknown when voice subsystem short-circuited (AudioGraphOwner nil)
public struct VoiceBootHealthProbe: BootHealthProbe {
public let name = "voice"
private let audioGraphOwner: AudioGraphOwner?
public init(audioGraphOwner: AudioGraphOwner?) {
self.audioGraphOwner = audioGraphOwner
}
public func probe() async -> ProbeOutcome {
let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
guard let owner = audioGraphOwner else {
// Round 4 — dormant voice install is a soft state: text input
// still works fine.
return ProbeOutcome(
status: .unknown(reason: "voice subsystem not installed (dormantVoiceContinuation nil or wakeword/VAD weights missing)", severity: .soft),
evidence: "AudioGraphOwner unavailable; mic TCC=\(authStatusName(micStatus))"
)
}
let variant = await owner.currentVariant
let route = await owner.activeRouteSnapshot()
switch micStatus {
case .denied, .restricted:
// Round 4 — voice loop dead, but text still works. Loud.
return ProbeOutcome(
status: .degraded(reason: "microphone TCC denied", severity: .loud),
evidence: "graph variant=\(variant.map { String(describing: $0) } ?? "nil"); mic=\(authStatusName(micStatus))"
)
case .notDetermined:
return ProbeOutcome(
status: .unknown(reason: "microphone TCC not yet requested", severity: .soft),
evidence: "graph variant=\(variant.map { String(describing: $0) } ?? "nil")"
)
case .authorized:
guard variant != nil else {
return ProbeOutcome(
status: .failed(reason: "AudioGraph never opened", severity: .loud),
evidence: "mic authorized but currentVariant=nil"
)
}
let routeDesc = route.map { "\($0.sampleRate.formatted())Hz × \($0.channels)ch" } ?? "no route"
return ProbeOutcome(
status: .ok,
evidence: "variant=\(variant!) — \(routeDesc)"
)
@unknown default:
return ProbeOutcome(
status: .unknown(reason: "unknown TCC status \(micStatus.rawValue)", severity: .soft),
evidence: "AVCaptureDevice.authorizationStatus returned unrecognized case"
)
}
}
}
// MARK: - WakeWord (Issue #33)
/// Watchdog for the wake-word DAG. Status `.ok` only when the feed Task
/// is armed against a ring. `.failed` when the DAG is dormant — fires
/// after audit-2026-05-12 P0-1's silent-death scenario (DAG cancelled
/// via `cancel()` and never re-armed).
///
/// Constructed against an optional `@Sendable` closure so the AppDelegate
/// can resolve the DAG lazily — `installVoice` may not have constructed
/// the DAG yet at probe-registration time (model files absent), and we
/// want one probe shape across "DAG armed" / "DAG dormant" / "DAG
/// never installed".
public struct WakeWordBootHealthProbe: BootHealthProbe {
public let name = "wake_word"
private let isArmed: @Sendable () async -> Bool?
/// - Parameter isArmed: closure returning `true` when the DAG's
/// feed Task is armed, `false` when stopped, `nil` when the DAG
/// was never installed (e.g. models missing).
public init(isArmed: @escaping @Sendable () async -> Bool?) {
self.isArmed = isArmed
}
public func probe() async -> ProbeOutcome {
guard let armed = await isArmed() else {
return ProbeOutcome(
status: .unknown(reason: "wake-word DAG not installed (models absent or mic denied)", severity: .soft),
evidence: "WakeWordDAG handle unavailable"
)
}
if armed {
return ProbeOutcome(status: .ok, evidence: "feed Task armed")
}
return ProbeOutcome(
status: .failed(reason: "wake-word DAG feed Task not armed", severity: .loud),
evidence: "WakeWordDAG.isFeedArmed=false — wake-word will not fire until re-armed"
)
}
}
// MARK: - STT (Issue #33)
/// Probes Apple Speech recognition authorization status as a proxy for
/// SpeechAnalyzer asset availability. macOS 26 SpeechAnalyzer requires
/// the `com.apple.developer.speech-recognition-assets` entitlement +
/// `NSSpeechRecognitionAssetsUsageDescription` Info.plist key for
/// on-device asset download; missing either causes silent
/// `SFSpeechErrorCode.assetUnavailable` on first transcription.
///
/// `SFSpeechRecognizer.authorizationStatus()` returns synchronously
/// without TCC prompts (it just reads the current state), so this
/// probe is safe to run at boot.
public struct STTBootHealthProbe: BootHealthProbe {
public let name = "stt"
public init() {}
public func probe() async -> ProbeOutcome {
let status = SFSpeechRecognizer.authorizationStatus()
switch status {
case .authorized:
return ProbeOutcome(
status: .ok,
evidence: "SFSpeechRecognizer authorized"
)
case .denied, .restricted:
return ProbeOutcome(
status: .degraded(reason: "speech recognition TCC denied", severity: .loud),
evidence: "SFSpeechRecognizer status=\(String(describing: status))"
)
case .notDetermined:
return ProbeOutcome(
status: .unknown(reason: "speech recognition TCC not yet requested", severity: .soft),
evidence: "SFSpeechRecognizer status=notDetermined"
)
@unknown default:
return ProbeOutcome(
status: .unknown(reason: "unknown SFSpeechRecognizer status \(status.rawValue)", severity: .soft),
evidence: "SFSpeechRecognizer.authorizationStatus returned unrecognized case"
)
}
}
}
// MARK: - TTS (Issue #33)
/// Cheap watchdog for the TTS engine: verifies the adapter is wired
/// (engine non-nil). The prior `VoiceBootHealthProbe` reported `ok`
/// based only on `audioGraphOwner` + `micStatus`, so a `nil`-engine
/// adapter (the pre-Track-B-3 production state) never surfaced.
///
/// The probe doesn't actually synthesize at boot — the audit suggests
/// "synthesize empty string" as one option but that would emit
/// audible events into a not-yet-ready audio chain. Cheaper, safer:
/// check whether the adapter has an engine attached. The AppDelegate
/// closure returns true when the TTS adapter was constructed with a
/// real `TTSEngineActor` and false when the dormant path is wired.
public struct TTSBootHealthProbe: BootHealthProbe {
public let name = "tts"
private let engineAlive: @Sendable () async -> Bool
public init(engineAlive: @escaping @Sendable () async -> Bool) {
self.engineAlive = engineAlive
}
public func probe() async -> ProbeOutcome {
if await engineAlive() {
return ProbeOutcome(
status: .ok,
evidence: "TTSEngineActor wired"
)
}
return ProbeOutcome(
status: .degraded(reason: "TTS engine not wired (adapter dormant)", severity: .loud),
evidence: "VoiceTTSAdapter.engine=nil — synth calls no-op"
)
}
}
// MARK: - Vision
/// Camera TCC status + camera device enumeration. No camera frame is
/// captured — `AVCaptureDevice.DiscoverySession` is read-only and does
/// not trigger the TCC prompt (RESEARCH §Example 2).
public struct VisionBootHealthProbe: BootHealthProbe {
public let name = "vision"
public init() {}
public func probe() async -> ProbeOutcome {
let camStatus = AVCaptureDevice.authorizationStatus(for: .video)
let session = AVCaptureDevice.DiscoverySession(
deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera],
mediaType: .video,
position: .unspecified
)
let devices = session.devices
switch camStatus {
case .denied, .restricted:
// Round 4 — vision is optional. The agent works fine without
// it; soft severity per the slice plan.
return ProbeOutcome(
status: .failed(reason: "camera TCC denied", severity: .soft),
evidence: "\(devices.count) device(s) discovered; mic TCC=\(authStatusName(camStatus))"
)
case .notDetermined:
return ProbeOutcome(
status: .unknown(reason: "camera TCC not yet requested", severity: .soft),
evidence: "\(devices.count) device(s) discovered (read-only — no TCC prompt)"
)
case .authorized:
if devices.isEmpty {
return ProbeOutcome(
status: .degraded(reason: "no camera devices discovered", severity: .soft),
evidence: "AVCaptureDevice.DiscoverySession returned 0 devices"
)
}
let firstName = devices.first?.localizedName ?? "unknown"
return ProbeOutcome(
status: .ok,
evidence: "\(devices.count) device(s); primary=\(firstName)"
)
@unknown default:
return ProbeOutcome(
status: .unknown(reason: "unknown TCC status \(camStatus.rawValue)", severity: .soft),
evidence: "AVCaptureDevice.authorizationStatus returned unrecognized case"
)
}
}
}
// MARK: - MCP
/// MCPRuntime registered-tool count. Verifies the runtime build succeeded
/// and that the expected tools were registered into the in-process registry
/// plus stdio helpers (mcp-time / mcp-clipboard / mcp-applescript).
///
/// Expected tool count at full registration:
/// - 3 stdio helpers (mcp-time → get_time; mcp-clipboard → get_clipboard;
/// mcp-applescript → run_applescript)
/// - 1 memory-stats (registered in installSelfKnowledgeTools)
/// - 4 self-knowledge (list_audio_devices, get_active_audio_route,
/// get_self_state, list_camera_devices)
/// - 0–2 memory tools (search_memory + forget_fact when vec0 + embedder up)
///
/// Anything under 8 is degraded; anything at zero is failed.
public struct MCPBootHealthProbe: BootHealthProbe {
public let name = "mcp"
private let mcpRuntime: MCPRuntime?
private let inProcessRegistry: InProcessToolRegistry?
private let minimumExpected: Int
public init(
mcpRuntime: MCPRuntime?,
inProcessRegistry: InProcessToolRegistry? = nil,
minimumExpected: Int = 8
) {
self.mcpRuntime = mcpRuntime
self.inProcessRegistry = inProcessRegistry
self.minimumExpected = minimumExpected
}
public func probe() async -> ProbeOutcome {
guard let runtime = mcpRuntime else {
// Round 4 — no MCP = no tools = agent has no leverage on the host.
// Critical.
return ProbeOutcome(
status: .failed(reason: "MCPRuntime nil — build failed at install (helpers absent or unsigned?)", severity: .critical),
evidence: "mcpRuntime property was nil at probe time"
)
}
// Stdio helpers + in-process tools live in separate registries.
// The composite dispatcher routes both. The agent uses both.
// Probe must count both.
let stdioNames = await runtime.client.registeredToolNames()
let inProcessNames: [String]
if let registry = inProcessRegistry {
let tools = await registry.registered()
inProcessNames = tools.map(\.name)
} else {
inProcessNames = []
}
let names = Array(Set(stdioNames + inProcessNames)).sorted()
let count = names.count
if count == 0 {
return ProbeOutcome(
status: .failed(reason: "no tools registered", severity: .critical),
evidence: "no stdio + no in-process tools"
)
}
if count < minimumExpected {
return ProbeOutcome(
status: .degraded(reason: "expected ≥\(minimumExpected) tools, registry has \(count)", severity: .loud),
evidence: "stdio: \(stdioNames.count); in-process: \(inProcessNames.count); names: \(names.joined(separator: ", "))"
)
}
return ProbeOutcome(
status: .ok,
evidence: "\(count) tools (\(stdioNames.count) stdio + \(inProcessNames.count) in-process): \(names.joined(separator: ", "))"
)
}
}
// MARK: - Replay
/// Replay log file presence + writability. Doesn't insert/rollback (the
/// ReplayLog actor doesn't expose a probe API), but verifies the file
/// exists, is reachable on disk, and is in a writable directory.
public struct ReplayBootHealthProbe: BootHealthProbe {
public let name = "replay"
private let databaseURL: URL
private let replayLogPresent: Bool
public init(databaseURL: URL, replayLogPresent: Bool) {
self.databaseURL = databaseURL
self.replayLogPresent = replayLogPresent
}
public func probe() async -> ProbeOutcome {
guard replayLogPresent else {
// Round 4 — ReplayLog init failure is critical (no audit trail,
// no eval harness data, observability collapses).
return ProbeOutcome(
status: .failed(reason: "ReplayLog nil — open() failed at install", severity: .critical),
evidence: "AppDelegate.replayLog was nil"
)
}
let fm = FileManager.default
guard fm.fileExists(atPath: databaseURL.path) else {
// Round 4 — missing file (existing log got moved/deleted) is loud:
// a fresh one would be re-created, but the old one is gone.
return ProbeOutcome(
status: .failed(reason: "replay.sqlite missing at \(databaseURL.path)", severity: .loud),
evidence: "FileManager.fileExists == false"
)
}
let parent = databaseURL.deletingLastPathComponent()
guard fm.isWritableFile(atPath: parent.path) else {
return ProbeOutcome(
status: .failed(reason: "parent dir not writable: \(parent.path)", severity: .loud),
evidence: "FileManager.isWritableFile(parent) == false"
)
}
let size: Int64
if let attrs = try? fm.attributesOfItem(atPath: databaseURL.path),
let s = attrs[.size] as? Int64 {
size = s
} else {
size = 0
}
return ProbeOutcome(
status: .ok,
evidence: "\(databaseURL.lastPathComponent) — \(size) bytes; parent writable"
)
}
}
// MARK: - Webview
/// WKWebView ↔ Swift handshake state. `.armed` = HUD JS has acked the
/// protocol version → bus is live. Anything else is a failure or pending.
public struct WebviewBootHealthProbe: BootHealthProbe {
public let name = "webview"
private let bridge: WebviewBridge?
public init(bridge: WebviewBridge?) {
self.bridge = bridge
}
public func probe() async -> ProbeOutcome {
guard let bridge = bridge else {
// Round 4 — no bridge = no UI at all. Critical.
return ProbeOutcome(
status: .failed(reason: "WebviewBridge nil — never constructed", severity: .critical),
evidence: "webviewBridge property was nil at probe time"
)
}
let state = await MainActor.run { bridge.handshakeState }
switch state {
case .idle:
return ProbeOutcome(
status: .unknown(reason: "handshake never started", severity: .soft),
evidence: "HandshakeState.idle"
)
case .sentHello:
return ProbeOutcome(
status: .unknown(reason: "handshake in flight", severity: .soft),
evidence: "HandshakeState.sentHello (awaiting JS ack)"
)
case .armed:
return ProbeOutcome(
status: .ok,
evidence: "HandshakeState.armed — JS bus live"
)
case .mismatched(let swift, let js):
// Round 4 — protocol mismatch is non-recoverable without rebuild.
return ProbeOutcome(
status: .failed(reason: "protocol version mismatch — Swift=\(swift), JS=\(js); rebuild webview bundle", severity: .critical),
evidence: "HandshakeState.mismatched(\(swift), \(js))"
)
case .timedOut:
return ProbeOutcome(
status: .failed(reason: "JS never acked hello — webview bundle missing or crashed at startup", severity: .critical),
evidence: "HandshakeState.timedOut after 2s"
)
case .loadFailed(let reason):
// Audit-2026-05-12 S1 / #40 — bundle navigation failed before
// `didFinish` could fire. Same severity as `.timedOut`: the
// user sees no HUD until the bundle is rebuilt.
return ProbeOutcome(
status: .failed(reason: "webview bundle load failed: \(reason); rebuild webview bundle", severity: .critical),
evidence: "HandshakeState.loadFailed(\(reason))"
)
}
}
}
// MARK: - Dormant placeholder
/// Used when a subsystem never installed — e.g., `memoryStore` is nil
/// because `MemoryStore.init` threw at boot. We register this rather than
/// skipping registration so every subsystem has a row in the snapshot
/// (honest absence > silent omission).
public struct DormantSubsystemProbe: BootHealthProbe {
public let name: String
private let reason: String
private let severity: FailureSeverity
/// Round 4 — `severity` defaults to `.soft`. Callers wiring memory or
/// MCP placeholders override to `.critical` so an outright-failed
/// install doesn't get downgraded to "couldn't probe yet."
public init(subsystemName: String, reason: String, severity: FailureSeverity = .soft) {
self.name = subsystemName
self.reason = reason
self.severity = severity
}
public func probe() async -> ProbeOutcome {
ProbeOutcome(
status: .unknown(reason: reason, severity: severity),
evidence: "subsystem actor unavailable at probe-registration time"
)
}
}
// MARK: - Helpers
private func authStatusName(_ status: AVAuthorizationStatus) -> String {
switch status {
case .notDetermined: return "notDetermined"
case .restricted: return "restricted"
case .denied: return "denied"
case .authorized: return "authorized"
@unknown default: return "unknown(\(status.rawValue))"
}
}