bugger/Sources/Services/IncidentReportService.swift

140 lines
4.7 KiB
Swift

import Foundation
/// Tracks which bugs may be investigated automatically.
///
/// The first successful fetch of a process is a snapshot: those bugs are not
/// queued. A bug that appears later, while this process is still running, is
/// queued once it has a description or a screenshot, after a 15-minute wait.
/// Quitting during the wait drops the automatic run. The detail-view button
/// skips both rules.
@MainActor
@Observable
final class IncidentReportService {
static let shared = IncidentReportService()
static let detailGrace: TimeInterval = 15 * 60
private(set) var phases: [String: IncidentReportPhase] = [:]
private var didSnapshot = false
private var baseline: Set<String> = []
/// Automatic handling already decided for this process (timer armed, or a
/// manual run took its place). Prevents a second automatic run.
private var autoArmed: Set<String> = []
private var timers: [String: Task<Void, Never>] = [:]
private var queue: [Bug] = []
private var runningID: String?
private init() {}
func phase(for bugID: String) -> IncidentReportPhase? {
phases[bugID]
}
func displayPath(_ path: String) -> String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
guard path.hasPrefix(home) else { return path }
return "~" + path.dropFirst(home.count)
}
func noteFetch(_ bugs: [Bug]) {
if !didSnapshot {
baseline = Set(bugs.map(\.id))
didSnapshot = true
BuggerLog.info("IncidentReportService: launch snapshot \(baseline.count) bug(s), none queued")
return
}
guard AppStateService.shared.config?.incidentReportsEnabled ?? true else { return }
for bug in bugs where !baseline.contains(bug.id) {
armIfReady(bug)
}
}
/// Starts an investigation immediately. Ignores the launch snapshot and the
/// 15-minute wait. A second click while this bug is queued or running is ignored.
func investigateNow(_ bug: Bug) {
timers[bug.id]?.cancel()
timers[bug.id] = nil
autoArmed.insert(bug.id)
enqueue(bug)
}
private func armIfReady(_ bug: Bug) {
guard !autoArmed.contains(bug.id) else { return }
guard !isBusy(bug.id) else { return }
guard Self.hasDetail(bug) else {
if phases[bug.id] == nil {
phases[bug.id] = .waitingForDetail
}
return
}
autoArmed.insert(bug.id)
let fireAt = Date().addingTimeInterval(Self.detailGrace)
phases[bug.id] = .scheduled(fireAt: fireAt)
BuggerLog.info("IncidentReportService: scheduled \(bug.id) at \(fireAt)")
timers[bug.id] = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(Self.detailGrace * 1_000_000_000))
guard !Task.isCancelled, let self else { return }
self.timers[bug.id] = nil
let latest = BugStore.shared.bugs.first { $0.id == bug.id } ?? bug
self.enqueue(latest)
}
}
private func isBusy(_ bugID: String) -> Bool {
runningID == bugID || queue.contains { $0.id == bugID }
}
private func enqueue(_ bug: Bug) {
guard !isBusy(bug.id) else { return }
queue.append(bug)
phases[bug.id] = .queued
pump()
}
private func pump() {
guard runningID == nil, !queue.isEmpty else { return }
let bug = queue.removeFirst()
runningID = bug.id
phases[bug.id] = .running
BuggerLog.info("IncidentReportService: investigating \(bug.id)")
Task {
let phase = await IncidentAgentRunner.run(bug: bug)
self.runningID = nil
self.phases[bug.id] = phase
switch phase {
case .succeeded(let path):
await NotificationService.shared.deliverNotice(
title: "Incident report ready",
body: self.displayPath(path)
)
case .failed(let message):
await NotificationService.shared.deliverNotice(
title: "Incident report failed",
body: String(message.prefix(180))
)
default:
break
}
self.pump()
}
}
static func hasDetail(_ bug: Bug) -> Bool {
if let description = bug.description?.trimmingCharacters(in: .whitespacesAndNewlines),
!description.isEmpty {
return true
}
return !bug.screenshots.isEmpty
}
}
enum IncidentReportPhase: Equatable {
case waitingForDetail
case scheduled(fireAt: Date)
case queued
case running
case succeeded(path: String)
case failed(message: String)
}