bugger/Sources/Services/IncidentReportService.swift

179 lines
6.6 KiB
Swift

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