fix(incident): 让新缺陷的事故报告能写出并改存用户目录

- 报告默认写入 ~/.bugger/incidents,不再依赖 ~/Dev/aptsell
- 问题描述视为有效描述;启动即拉取,只自动排队上次进程之后新建的缺陷
- 钥匙串读取不再卡住菜单栏应用,锁屏解锁后再继续
- 项目 cli.json 去掉不被接受的 sandbox 字段

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
tigerenwork 2026-10-25 21:00:49 +08:00
parent f54f5175c2
commit f3746d092f
11 changed files with 322 additions and 39 deletions

View File

@ -28,7 +28,7 @@ struct AppConfig: Codable, Equatable {
/// concern. /// concern.
var calibrationEnabled: Bool = false var calibrationEnabled: Bool = false
/// When true, a bug that first appears while Bugger is running is investigated /// When true, a bug that first appears while Bugger is running is investigated
/// 15 minutes after it has a description or a screenshot. The launch snapshot /// 15 minutes after it has a description, a title (问题描述), or a screenshot. The launch snapshot
/// is never queued. The per-bug button works either way. /// is never queued. The per-bug button works either way.
var incidentReportsEnabled: Bool = true var incidentReportsEnabled: Bool = true

View File

@ -4,7 +4,7 @@ import Foundation
/// ///
/// The agent is started in ask mode with the sandbox on, and without `--force`, /// The agent is started in ask mode with the sandbox on, and without `--force`,
/// so it cannot change code or production data. Bugger saves the agent's final /// so it cannot change code or production data. Bugger saves the agent's final
/// message as the incident report under ~/Dev/aptsell/incidents. A permission /// message as the incident report under ~/.bugger/incidents. A permission
/// file also denies shell and mutating Grafana and TiDB tools. Temporary /// file also denies shell and mutating Grafana and TiDB tools. Temporary
/// screenshot copies are deleted after the run. /// screenshot copies are deleted after the run.
enum IncidentAgentRunner { enum IncidentAgentRunner {
@ -16,7 +16,9 @@ enum IncidentAgentRunner {
} }
static var incidentsDirectory: URL { static var incidentsDirectory: URL {
home.appendingPathComponent("Dev/aptsell/incidents", isDirectory: true) home
.appendingPathComponent(".bugger", isDirectory: true)
.appendingPathComponent("incidents", isDirectory: true)
} }
private static var agentBinary: URL { private static var agentBinary: URL {
@ -73,7 +75,7 @@ enum IncidentAgentRunner {
- Do not change code, git state, configuration, or anything in production. - Do not change code, git state, configuration, or anything in production.
- Do not run shell commands. Do not commit, push, or open a pull request. - Do not run shell commands. Do not commit, push, or open a pull request.
- Do not call any tool that creates, updates, deletes, installs, or writes data. - Do not call any tool that creates, updates, deletes, installs, or writes data.
- The incident report is required. Your final message is that report, in full markdown. Bugger saves it under ~/Dev/aptsell/incidents. That saved report is the only write. Do not write or edit any other file. - The incident report is required. Your final message is that report, in full markdown. Bugger saves it under ~/.bugger/incidents. That saved report is the only write. Do not write or edit any other file.
- Grafana: only list, get, search, and query tools. Never call create_*, update_*, delete_*, alerting_*_write, alerting_manage_routing, add_activity_to_incident, install_plugin, or grafana_api_request. - Grafana: only list, get, search, and query tools. Never call create_*, update_*, delete_*, alerting_*_write, alerting_manage_routing, add_activity_to_incident, install_plugin, or grafana_api_request.
- TiDB: only read (db_query, show_databases, show_tables, switch_database). Never call db_execute, db_create_user, or db_remove_user. Never send SQL that changes data. - TiDB: only read (db_query, show_databases, show_tables, switch_database). Never call db_execute, db_create_user, or db_remove_user. Never send SQL that changes data.
- Do not modify Feishu, the bug record, dashboards, alerts, or incidents in Grafana. - Do not modify Feishu, the bug record, dashboards, alerts, or incidents in Grafana.
@ -112,6 +114,8 @@ enum IncidentAgentRunner {
""") """)
if let description = bug.description?.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty { if let description = bug.description?.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty {
lines.append("Description:\n\(description)") lines.append("Description:\n\(description)")
} else if bug.title != "Untitled" {
lines.append("Description (问题描述):\n\(bug.title)")
} else { } else {
lines.append("Description: (none)") lines.append("Description: (none)")
} }
@ -370,6 +374,8 @@ private enum RunnerError: LocalizedError {
/// Writes the workspace permission file the agent session merges in. /// Writes the workspace permission file the agent session merges in.
/// Arrays in cli.json replace the home config's arrays for this session only. /// Arrays in cli.json replace the home config's arrays for this session only.
/// Project cli.json accepts permissions only. Sandbox is turned on with the
/// `--sandbox enabled` process flag, which overrides the home config.
private enum IncidentAgentPolicy { private enum IncidentAgentPolicy {
static func write(into incidentsDirectory: URL) throws { static func write(into incidentsDirectory: URL) throws {
let directory = incidentsDirectory.appendingPathComponent(".cursor", isDirectory: true) let directory = incidentsDirectory.appendingPathComponent(".cursor", isDirectory: true)
@ -379,10 +385,6 @@ private enum IncidentAgentPolicy {
"allow": allow, "allow": allow,
"deny": deny, "deny": deny,
], ],
"sandbox": [
"mode": "enabled",
"networkAccess": "allow_all",
],
] ]
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
try data.write(to: directory.appendingPathComponent("cli.json")) try data.write(to: directory.appendingPathComponent("cli.json"))

View File

@ -2,9 +2,10 @@ import Foundation
/// Tracks which bugs may be investigated automatically. /// Tracks which bugs may be investigated automatically.
/// ///
/// The first successful fetch of a process is a snapshot: those bugs are not /// Bugs created before this process (or before the previous run, so a bug that
/// queued. A bug that appears later, while this process is still running, is /// arrived while the last process was up is not lost) are the baseline and are
/// queued once it has a description or a screenshot, after a 15-minute wait. /// 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 /// Quitting during the wait drops the automatic run. The detail-view button
/// skips both rules. /// skips both rules.
@MainActor @MainActor
@ -18,6 +19,10 @@ final class IncidentReportService {
private var didSnapshot = false private var didSnapshot = false
private var baseline: Set<String> = [] 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 /// Automatic handling already decided for this process (timer armed, or a
/// manual run took its place). Prevents a second automatic run. /// manual run took its place). Prevents a second automatic run.
private var autoArmed: Set<String> = [] private var autoArmed: Set<String> = []
@ -25,7 +30,17 @@ final class IncidentReportService {
private var queue: [Bug] = [] private var queue: [Bug] = []
private var runningID: String? private var runningID: String?
private init() {} 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? { func phase(for bugID: String) -> IncidentReportPhase? {
phases[bugID] phases[bugID]
@ -39,10 +54,13 @@ final class IncidentReportService {
func noteFetch(_ bugs: [Bug]) { func noteFetch(_ bugs: [Bug]) {
if !didSnapshot { if !didSnapshot {
baseline = Set(bugs.map(\.id)) // 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 didSnapshot = true
BuggerLog.info("IncidentReportService: launch snapshot \(baseline.count) bug(s), none queued") let arrived = bugs.filter { !baseline.contains($0.id) }.count
return BuggerLog.info("IncidentReportService: snapshot \(baseline.count) existing, \(arrived) new since \(eligibleAfter)")
} }
guard AppStateService.shared.config?.incidentReportsEnabled ?? true else { return } guard AppStateService.shared.config?.incidentReportsEnabled ?? true else { return }
for bug in bugs where !baseline.contains(bug.id) { for bug in bugs where !baseline.contains(bug.id) {
@ -69,11 +87,27 @@ final class IncidentReportService {
return return
} }
autoArmed.insert(bug.id) autoArmed.insert(bug.id)
let fireAt = Date().addingTimeInterval(Self.detailGrace) // 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) phases[bug.id] = .scheduled(fireAt: fireAt)
BuggerLog.info("IncidentReportService: scheduled \(bug.id) at \(fireAt)") BuggerLog.info("IncidentReportService: scheduled \(bug.id) at \(fireAt)")
timers[bug.id] = Task { [weak self] in timers[bug.id] = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(Self.detailGrace * 1_000_000_000)) try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
guard !Task.isCancelled, let self else { return } guard !Task.isCancelled, let self else { return }
self.timers[bug.id] = nil self.timers[bug.id] = nil
let latest = BugStore.shared.bugs.first { $0.id == bug.id } ?? bug let latest = BugStore.shared.bugs.first { $0.id == bug.id } ?? bug
@ -125,6 +159,11 @@ final class IncidentReportService {
!description.isEmpty { !description.isEmpty {
return true 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 return !bug.screenshots.isEmpty
} }
} }

View File

@ -12,6 +12,7 @@ final class PollerService {
private var timer: Timer? private var timer: Timer?
private var lastFetchTime: Date? private var lastFetchTime: Date?
private var unlockObserver: NSObjectProtocol?
private let fetchGate = FetchGate() private let fetchGate = FetchGate()
private(set) var isRunning = false private(set) var isRunning = false
@ -19,11 +20,25 @@ final class PollerService {
private init() {} private init() {}
func startIfConfigured() async { func startIfConfigured() async {
guard let config = configService.config, switch await tokenManager.approveKeychainIfNeeded() {
config.isConfigured, case .ready:
tokenManager.isAuthenticated else { break
case .locked:
waitForUnlock()
return
case .unavailable:
BuggerLog.error("PollerService: not authenticated, incident reports will not run")
return return
} }
guard let config = configService.config, config.isConfigured else {
BuggerLog.error("PollerService: not configured, incident reports will not run")
return
}
guard tokenManager.isAuthenticated else {
BuggerLog.error("PollerService: not authenticated, incident reports will not run")
return
}
BuggerLog.info("PollerService: starting")
start(interval: TimeInterval(config.pollIntervalSeconds), fetchImmediately: config.refreshOnStart) start(interval: TimeInterval(config.pollIntervalSeconds), fetchImmediately: config.refreshOnStart)
} }
@ -31,7 +46,11 @@ final class PollerService {
guard !isRunning else { return } guard !isRunning else { return }
isRunning = true isRunning = true
if fetchImmediately { // Incident reports need a fetch at launch. Otherwise the first fetch
// can be hours later (daily schedule, refresh-on-launch off) and is
// the change that delivered the new bug.
let investigate = configService.config?.incidentReportsEnabled ?? true
if fetchImmediately || investigate {
Task { await performFetch() } Task { await performFetch() }
} }
@ -49,6 +68,26 @@ final class PollerService {
} }
} }
/// Keychain Allow dialogs cannot appear while the screen is locked.
/// Retry the launch fetch once the user unlocks.
private func waitForUnlock() {
guard unlockObserver == nil else { return }
BuggerLog.info("Screen is locked, so the Feishu token cannot be read. Incident reports start after unlock.")
unlockObserver = DistributedNotificationCenter.default().addObserver(
forName: Notification.Name("com.apple.screenIsUnlocked"),
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
if let token = self.unlockObserver {
DistributedNotificationCenter.default().removeObserver(token)
self.unlockObserver = nil
}
BuggerLog.info("Screen unlocked, reading the Feishu token")
Task { await self.startIfConfigured() }
}
}
func stop() { func stop() {
timer?.invalidate() timer?.invalidate()
timer = nil timer = nil
@ -151,6 +190,7 @@ final class PollerService {
userOpenId: openId userOpenId: openId
) )
BuggerLog.info("PollerService: fetched \(bugs.count) bugs")
await MainActor.run { await MainActor.run {
bugStore.update(with: bugs) bugStore.update(with: bugs)
bugStore.setLoading(false) bugStore.setLoading(false)
@ -158,11 +198,13 @@ final class PollerService {
IncidentReportService.shared.noteFetch(bugs) IncidentReportService.shared.noteFetch(bugs)
} }
} catch FeishuError.unauthorized { } catch FeishuError.unauthorized {
BuggerLog.error("PollerService: unauthorized")
await MainActor.run { await MainActor.run {
bugStore.setLoading(false) bugStore.setLoading(false)
tokenManager.clearTokens() tokenManager.clearTokens()
} }
} catch { } catch {
BuggerLog.error("PollerService: fetch failed \(error.localizedDescription)")
await MainActor.run { await MainActor.run {
bugStore.setLoading(false) bugStore.setLoading(false)
bugStore.setError(error) bugStore.setError(error)

View File

@ -1,3 +1,5 @@
import AppKit
import CoreGraphics
import Foundation import Foundation
@Observable @Observable
@ -34,6 +36,53 @@ final class TokenManager {
return false return false
} }
enum KeychainGate {
case ready
case locked
case unavailable
}
/// The login-keychain item is tied to the code signature that wrote it.
/// An ad-hoc rebuild cannot read it until the user clicks Allow. The main
/// thread has to stay free so that dialog can appear. A locked screen
/// cannot present it at all.
@MainActor
func approveKeychainIfNeeded() async -> KeychainGate {
if isAuthenticated { return .ready }
guard keychain.needsApproval(accessTokenKey) else { return .unavailable }
if Self.screenIsLocked() {
return .locked
}
BuggerLog.info("Keychain approval required for the Feishu token")
let previous = NSApp.activationPolicy()
NSApp.setActivationPolicy(.regular)
NSApp.activate()
try? await Task.sleep(nanoseconds: 300_000_000)
let key = accessTokenKey
let helper = keychain
let token = await Task.detached {
helper.readPrompting(key)
}.value
if previous == .accessory {
NSApp.setActivationPolicy(.accessory)
}
if token != nil {
state = .authenticated
BuggerLog.info("Keychain approval granted")
return .ready
}
BuggerLog.error("Keychain approval was not granted")
return .unavailable
}
private static func screenIsLocked() -> Bool {
guard let raw = CGSessionCopyCurrentDictionary() else { return false }
let value = (raw as NSDictionary)["CGSSessionScreenIsLocked"]
if let locked = value as? Bool { return locked }
if let number = value as? NSNumber { return number.intValue != 0 }
return false
}
var authorizeURL: URL? { var authorizeURL: URL? {
guard authService.hasCredentials else { return nil } guard authService.hasCredentials else { return nil }
return authService.authorizeURL return authService.authorizeURL

View File

@ -10,30 +10,47 @@ final class KeychainHelper {
func write(_ value: String, forKey key: String) { func write(_ value: String, forKey key: String) {
let data = Data(value.utf8) let data = Data(value.utf8)
let query: [String: Any] = [ delete(key)
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service, kSecAttrService as String: service,
kSecAttrAccount as String: key, kSecAttrAccount as String: key,
kSecValueData as String: data kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
] ]
SecItemDelete(query as CFDictionary) if let access = accessForAnyApplication() {
SecItemAdd(query as CFDictionary, nil) query[kSecAttrAccess as String] = access
}
let status = SecItemAdd(query as CFDictionary, nil)
if status != errSecSuccess {
BuggerLog.error("Keychain write \(key) failed (\(status))")
}
} }
/// Never shows UI. A prompting read from a background thread blocks forever
/// without a dialog when the app is a menu-bar item.
func read(_ key: String) -> String? { func read(_ key: String) -> String? {
let query: [String: Any] = [ let result = copyMatching(key, prompt: false)
kSecClass as String: kSecClassGenericPassword, switch result.status {
kSecAttrService as String: service, case errSecSuccess, errSecItemNotFound:
kSecAttrAccount as String: key, return result.value
kSecReturnData as String: true, case errSecInteractionNotAllowed, errSecAuthFailed:
kSecMatchLimit as String: kSecMatchLimitOne return nil
] default:
var result: AnyObject? BuggerLog.error("Keychain read \(key) failed (\(result.status))")
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else {
return nil return nil
} }
return String(data: data, encoding: .utf8) }
func needsApproval(_ key: String) -> Bool {
let status = copyMatching(key, prompt: false).status
return status == errSecInteractionNotAllowed || status == errSecAuthFailed
}
/// Call on the main thread, after the app is a regular foreground app,
/// so the Allow dialog is visible.
func readPrompting(_ key: String) -> String? {
copyMatching(key, prompt: true).value
} }
func delete(_ key: String) { func delete(_ key: String) {
@ -44,4 +61,51 @@ final class KeychainHelper {
] ]
SecItemDelete(query as CFDictionary) SecItemDelete(query as CFDictionary)
} }
private func copyMatching(_ key: String, prompt: Bool) -> (value: String?, status: OSStatus) {
// These items live in the legacy login keychain. kSecUseAuthenticationUI
// is ignored there, and a background read waits forever on a dialog
// that a menu-bar app never shows.
SecKeychainSetUserInteractionAllowed(prompt)
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
if !prompt {
query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail
}
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return (nil, status)
}
return (String(data: data, encoding: .utf8), status)
}
/// Each ad-hoc build has a new code signature. The default keychain ACL
/// then blocks in a permission dialog, and launch never reaches the fetch.
/// Trust every application on this Mac so the next build can still read
/// the Feishu tokens.
private func accessForAnyApplication() -> SecAccess? {
var access: SecAccess?
guard SecAccessCreate("Bugger" as CFString, nil, &access) == errSecSuccess,
let access else { return nil }
var list: CFArray?
guard SecAccessCopyACLList(access, &list) == errSecSuccess,
let acls = list as? [SecACL] else {
return access
}
for acl in acls {
var applications: CFArray?
var description: CFString?
var prompt = SecKeychainPromptSelector()
guard SecACLCopyContents(acl, &applications, &description, &prompt) == errSecSuccess else { continue }
let label = description ?? "Bugger" as CFString
SecACLSetContents(acl, nil, label, [])
}
return access
}
} }

View File

@ -5,16 +5,41 @@ import OSLog
/// (filter by subsystem: com.xorbitlab.bugger). /// (filter by subsystem: com.xorbitlab.bugger).
enum BuggerLog { enum BuggerLog {
private static let logger = Logger(subsystem: "com.xorbitlab.bugger", category: "general") private static let logger = Logger(subsystem: "com.xorbitlab.bugger", category: "general")
private static let queue = DispatchQueue(label: "com.xorbitlab.bugger.log")
private static let logURL: URL = {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Logs/Bugger.log")
}()
static func info(_ message: String) { static func info(_ message: String) {
logger.info("\(message, privacy: .public)") logger.info("\(message, privacy: .public)")
write("INFO", message)
} }
static func error(_ message: String) { static func error(_ message: String) {
logger.error("\(message, privacy: .public)") logger.error("\(message, privacy: .public)")
write("ERROR", message)
} }
static func debug(_ message: String) { static func debug(_ message: String) {
logger.debug("\(message, privacy: .public)") logger.debug("\(message, privacy: .public)")
write("DEBUG", message)
}
/// Unified logging for this ad-hoc app has been empty, so keep a file too.
private static func write(_ level: String, _ message: String) {
queue.async {
let stamp = ISO8601DateFormatter().string(from: Date())
let line = "\(stamp) \(level) \(message)\n"
guard let data = line.data(using: .utf8) else { return }
if FileManager.default.fileExists(atPath: logURL.path),
let handle = try? FileHandle(forWritingTo: logURL) {
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
} else {
try? data.write(to: logURL)
}
}
} }
} }

View File

@ -130,9 +130,9 @@ struct BugDetailView: View {
private func incidentStatus(_ phase: IncidentReportPhase?) -> String { private func incidentStatus(_ phase: IncidentReportPhase?) -> String {
switch phase { switch phase {
case nil: case nil:
return "Read-only. Writes a Chinese and English report under ~/Dev/aptsell/incidents. Does not change code or data." return "Read-only. Writes a Chinese and English report under ~/.bugger/incidents. Does not change code or data."
case .waitingForDetail: case .waitingForDetail:
return "Automatic report waits until this bug has a description or a screenshot, then another 15 minutes." return "Automatic report waits until this bug has a description (问题描述 counts) or a screenshot, then another 15 minutes."
case .scheduled(let fireAt): case .scheduled(let fireAt):
return "Automatic report at \(fireAt.formatted(date: .omitted, time: .shortened)). It only reads logs and code." return "Automatic report at \(fireAt.formatted(date: .omitted, time: .shortened)). It only reads logs and code."
case .queued: case .queued:

View File

@ -96,7 +96,7 @@ struct SettingsView: View {
// MARK: Incident reports // MARK: Incident reports
settingsSection("Incident reports") { settingsSection("Incident reports") {
Toggle("Investigate new bugs", isOn: $config.incidentReportsEnabled) Toggle("Investigate new bugs", isOn: $config.incidentReportsEnabled)
Text("When a bug arrives while Bugger is running and it has a description or a screenshot, wait 15 minutes, then write a read-only incident report to ~/Dev/aptsell/incidents. Bugs already loaded at launch are skipped. The button on a bug runs immediately.") Text("When a bug arrives while Bugger is running and it has a description (问题描述 counts) or a screenshot, wait 15 minutes, then write a read-only incident report to ~/.bugger/incidents. Bugs already loaded at launch are skipped. The button on a bug runs immediately.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)

View File

@ -0,0 +1,24 @@
# FSD:只读事故报告
| 要素 | 内容 |
|------|------|
| 时间 | 2026-10-25 |
| 关联服务 | Bugger(macOS) |
| 关联功能模块 | incident |
| 提交 | |
技术规格:[tsd_incident_report.md](../../tsd/incident/tsd_incident_report.md).
## 1. 行为
| # | 行为 |
|---|------|
| F1 | 设置里“Investigate new bugs”默认开启。Bugger 运行期间新出现、且有问题描述、描述或截图的缺陷会生成中英双语只读事故报告。启动时已经存在的缺陷不会自动排队。 |
| F2 | 自动调查从缺陷创建时间起等待 15 分钟,便于补全飞书记录。已经超过 15 分钟则马上开始。详情页按钮跳过等待和基线,立即调查。 |
| F3 | 报告文件出现在 `~/.bugger/incidents`。不需要预先创建 `~/Dev/aptsell`。调查不改代码、不改生产数据。 |
| F4 | 屏幕锁定时不弹出钥匙串授权。解锁后继续登录并拉取。需要授权时出现 Allow 对话框。 |
| F5 | 调查进行中按钮显示等待状态,窗口其余部分仍可使用。完成后用系统通知给出报告路径或失败原因。 |
## 变更记录
- 2026-10-25 fix(incident): 让新缺陷的事故报告能写出并改存用户目录 — 默认目录、问题描述计数、锁屏后继续

View File

@ -0,0 +1,38 @@
# TSD:只读事故报告
| 要素 | 内容 |
|------|------|
| 时间 | 2026-10-25 |
| 关联服务 | Bugger(macOS) |
| 关联功能模块 | incident |
| 提交 | |
功能规格:[fsd_incident_report.md](../../fsd/incident/fsd_incident_report.md).
## 1. 决定
| # | 决定 |
|---|------|
| P1 | 报告由 Bugger 写入 `~/.bugger/incidents`。目录在首次运行时创建。cursor-agent 的工作区就是该目录,代码仓库仍通过 `--add-dir` 加入。 |
| P2 | 自动排队只包含 `createdAt` 不早于上一进程启动时间的缺陷。进程启动时间记在 `bugger.incidentProcessStartedAt`。启动时已存在的缺陷不自动排队。 |
| P3 | 问题描述映射为标题。非空且不是 `Untitled` 的标题、描述或截图都算作已有细节。等待从 `createdAt` 起算 15 分钟,已超过则立即调查。 |
| P4 | 事故报告开启时,启动即拉取一次,不依赖“启动时刷新”或每日时刻。 |
| P5 | 项目 `.cursor/cli.json` 只写 permissions。沙箱由进程参数 `--sandbox enabled` 打开。 |
| P6 | 钥匙串读取默认不弹窗。需要授权时把应用切到前台,在后台线程弹出 Allow,主线程保持可绘制。锁屏时不弹窗,解锁后再试一次。新写入的项允许本机任意应用读取,避免 ad-hoc 重签名后启动卡死。 |
| P7 | 同一进程同时只跑一个 cursor-agent。日志追加到 `~/Library/Logs/Bugger.log`。 |
## 2. 代码
- `Sources/Services/IncidentAgentRunner.swift`
- `Sources/Services/IncidentReportService.swift`
- `Sources/Services/PollerService.swift`
- `Sources/Services/TokenManager.swift`
- `Sources/Utils/KeychainHelper.swift`
- `Sources/Utils/Logger.swift`
- `Sources/Views/MainWindow/BugDetailView.swift`
- `Sources/Views/Settings/SettingsView.swift`
- `Sources/Models/AppConfig.swift`
## 变更记录
- 2026-10-25 fix(incident): 让新缺陷的事故报告能写出并改存用户目录 — 报告目录、排队条件、启动拉取、钥匙串授权与项目配置