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 = [] /// 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 = [] private var timers: [String: Task] = [:] 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) }