import Foundation /// Tracks which bugs may be investigated automatically. /// /// Bugs created before this process (or before the previous run, so a bug that /// arrived while the last process was up is not lost) are the baseline and are /// not queued. A bug created while Bugger is running is queued once it has a /// description, a title (问题描述), 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 = [] /// Bugs created at or after this instant are eligible. It is the start of /// the previous process when that was recorded, so a bug that arrived /// while that process was running is still picked up after a relaunch. private let eligibleAfter: Date /// 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 static let processStartedAtKey = "bugger.incidentProcessStartedAt" private init() { let now = Date() if let previous = UserDefaults.standard.object(forKey: Self.processStartedAtKey) as? Date { eligibleAfter = previous } else { eligibleAfter = now } UserDefaults.standard.set(now, forKey: Self.processStartedAtKey) } 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 { // Only bugs that already existed before this run are the snapshot. // A bug created while Bugger is running must still be queued when // the first fetch of the process is the one that sees it. baseline = Set(bugs.filter { $0.createdAt < eligibleAfter }.map(\.id)) didSnapshot = true let arrived = bugs.filter { !baseline.contains($0.id) }.count BuggerLog.info("IncidentReportService: snapshot \(baseline.count) existing, \(arrived) new since \(eligibleAfter)") } 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) // The wait is so the Feishu record can be filled in. If the bug was // filed more than 15 minutes ago, that wait has already elapsed. let elapsed = Date().timeIntervalSince(bug.createdAt) let remaining: TimeInterval if elapsed >= Self.detailGrace { remaining = 0 } else if elapsed >= 0 { remaining = Self.detailGrace - elapsed } else { remaining = Self.detailGrace } if remaining == 0 { BuggerLog.info("IncidentReportService: \(bug.id) already past the wait, investigating now") enqueue(bug) return } let fireAt = Date().addingTimeInterval(remaining) 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(remaining * 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 } // 问题描述 is mapped as the title. A text-only bug still has enough to investigate. let title = bug.title.trimmingCharacters(in: .whitespacesAndNewlines) if !title.isEmpty, title != "Untitled" { 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) }