140 lines
4.7 KiB
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)
|
|
}
|