Compare commits

..

No commits in common. "f3746d092fd1696124bc452e3e3d12e3d60ba4d8" and "4cf0e9e72cb5a55bef9640360a06ab9845f6e569" have entirely different histories.

14 changed files with 26 additions and 1041 deletions

View File

@ -24,8 +24,6 @@
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3; }; CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3; };
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C; }; CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C; };
29EE2FE56882F97E14AAF58F /* FeishuService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21AC33F380B7DC34C39B61A6; }; 29EE2FE56882F97E14AAF58F /* FeishuService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21AC33F380B7DC34C39B61A6; };
DB2FC73D43E91946A32816D7 /* IncidentAgentRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81026F81FDE9FEE81028963; };
DC17587DB064B6F31955809D /* IncidentReportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980BB4E71F795F8A0A092C1A; };
6148104B311B60C79B3B8572 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CC175DE8E9FE3250757D33; }; 6148104B311B60C79B3B8572 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59CC175DE8E9FE3250757D33; };
56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4628E7B599B69B499E09E9; }; 56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4628E7B599B69B499E09E9; };
B882D310EB2B5D032375CDDF /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3775F77DD36C27160A8C; }; B882D310EB2B5D032375CDDF /* TokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7E3775F77DD36C27160A8C; };
@ -67,8 +65,6 @@
2A83E76FBB83849E4B6704D3 /* FeishuError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuError.swift; sourceTree = "<group>"; }; 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuError.swift; sourceTree = "<group>"; };
FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = "<group>"; }; FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = "<group>"; };
21AC33F380B7DC34C39B61A6 /* FeishuService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuService.swift; sourceTree = "<group>"; }; 21AC33F380B7DC34C39B61A6 /* FeishuService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuService.swift; sourceTree = "<group>"; };
A81026F81FDE9FEE81028963 /* IncidentAgentRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncidentAgentRunner.swift; sourceTree = "<group>"; };
980BB4E71F795F8A0A092C1A /* IncidentReportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncidentReportService.swift; sourceTree = "<group>"; };
59CC175DE8E9FE3250757D33 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 59CC175DE8E9FE3250757D33 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
4F4628E7B599B69B499E09E9 /* PollerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollerService.swift; sourceTree = "<group>"; }; 4F4628E7B599B69B499E09E9 /* PollerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollerService.swift; sourceTree = "<group>"; };
0E7E3775F77DD36C27160A8C /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = "<group>"; }; 0E7E3775F77DD36C27160A8C /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = "<group>"; };
@ -148,8 +144,6 @@
E834D262A6EE1D5406686761 /* BitableEventService.swift */, E834D262A6EE1D5406686761 /* BitableEventService.swift */,
D3CA07056AACE5378CF9D421 /* BugEditService.swift */, D3CA07056AACE5378CF9D421 /* BugEditService.swift */,
52C52B8EDA29A06D9DAA6E5D /* CalibrationService.swift */, 52C52B8EDA29A06D9DAA6E5D /* CalibrationService.swift */,
A81026F81FDE9FEE81028963 /* IncidentAgentRunner.swift */,
980BB4E71F795F8A0A092C1A /* IncidentReportService.swift */,
59CC175DE8E9FE3250757D33 /* NotificationService.swift */, 59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
4F4628E7B599B69B499E09E9 /* PollerService.swift */, 4F4628E7B599B69B499E09E9 /* PollerService.swift */,
0E7E3775F77DD36C27160A8C /* TokenManager.swift */, 0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
@ -358,8 +352,6 @@
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */, CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */,
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */, CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */,
29EE2FE56882F97E14AAF58F /* FeishuService.swift in Sources */, 29EE2FE56882F97E14AAF58F /* FeishuService.swift in Sources */,
DB2FC73D43E91946A32816D7 /* IncidentAgentRunner.swift in Sources */,
DC17587DB064B6F31955809D /* IncidentReportService.swift in Sources */,
6148104B311B60C79B3B8572 /* NotificationService.swift in Sources */, 6148104B311B60C79B3B8572 /* NotificationService.swift in Sources */,
56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */, 56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */,
B882D310EB2B5D032375CDDF /* TokenManager.swift in Sources */, B882D310EB2B5D032375CDDF /* TokenManager.swift in Sources */,
@ -413,7 +405,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 6;
FEISHU_APP_ID = "$(FEISHU_APP_ID)"; FEISHU_APP_ID = "$(FEISHU_APP_ID)";
FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)"; FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)";
FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)"; FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)";
@ -421,7 +413,7 @@
INFOPLIST_FILE = Resources/Info.plist; INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.7; MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger; PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@ -434,7 +426,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 7; CURRENT_PROJECT_VERSION = 6;
FEISHU_APP_ID = "$(FEISHU_APP_ID)"; FEISHU_APP_ID = "$(FEISHU_APP_ID)";
FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)"; FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)";
FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)"; FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)";
@ -442,7 +434,7 @@
INFOPLIST_FILE = Resources/Info.plist; INFOPLIST_FILE = Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.7; MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger; PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;

View File

@ -27,10 +27,6 @@ struct AppConfig: Codable, Equatable {
/// only useful when feishuAppBaseURL is set and push reliability is a /// only useful when feishuAppBaseURL is set and push reliability is a
/// concern. /// concern.
var calibrationEnabled: Bool = false var calibrationEnabled: Bool = false
/// When true, a bug that first appears while Bugger is running is investigated
/// 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.
var incidentReportsEnabled: Bool = true
struct FieldMappings: Codable, Equatable { struct FieldMappings: Codable, Equatable {
var titleField: String = "问题描述" var titleField: String = "问题描述"
@ -119,7 +115,7 @@ struct AppConfig: Codable, Equatable {
case appToken, tableId, assigneeName, fieldMappings case appToken, tableId, assigneeName, fieldMappings
case pollIntervalSeconds, dailyRefreshTimes case pollIntervalSeconds, dailyRefreshTimes
case showFloatingWidget, launchAtLogin, feishuBaseDomain, refreshOnStart case showFloatingWidget, launchAtLogin, feishuBaseDomain, refreshOnStart
case feishuAppBaseURL, calibrationEnabled, incidentReportsEnabled case feishuAppBaseURL, calibrationEnabled
} }
init() {} init() {}
@ -138,7 +134,6 @@ struct AppConfig: Codable, Equatable {
refreshOnStart = try c.decodeIfPresent(Bool.self, forKey: .refreshOnStart) ?? false refreshOnStart = try c.decodeIfPresent(Bool.self, forKey: .refreshOnStart) ?? false
feishuAppBaseURL = try c.decodeIfPresent(String.self, forKey: .feishuAppBaseURL) ?? "" feishuAppBaseURL = try c.decodeIfPresent(String.self, forKey: .feishuAppBaseURL) ?? ""
calibrationEnabled = try c.decodeIfPresent(Bool.self, forKey: .calibrationEnabled) ?? false calibrationEnabled = try c.decodeIfPresent(Bool.self, forKey: .calibrationEnabled) ?? false
incidentReportsEnabled = try c.decodeIfPresent(Bool.self, forKey: .incidentReportsEnabled) ?? true
} }
} }

View File

@ -1,484 +0,0 @@
import Foundation
/// Runs cursor-agent as a read-only investigation and writes the report itself.
///
/// 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
/// message as the incident report under ~/.bugger/incidents. A permission
/// file also denies shell and mutating Grafana and TiDB tools. Temporary
/// screenshot copies are deleted after the run.
enum IncidentAgentRunner {
static let lookbackDays = 14
static let runTimeout: TimeInterval = 40 * 60
private static var home: URL {
FileManager.default.homeDirectoryForCurrentUser
}
static var incidentsDirectory: URL {
home
.appendingPathComponent(".bugger", isDirectory: true)
.appendingPathComponent("incidents", isDirectory: true)
}
private static var agentBinary: URL {
home.appendingPathComponent(".local/bin/cursor-agent")
}
private static let repositories = [
"Dev/aptsell/Aldebaran",
"Dev/aptsell/aptsell-base-api",
"Dev/aptsell/aptsell-oauth",
"Dev/aptsell/opencode-bundle/opencode-agent",
"Dev/aptsell/opencode-bundle/opencode-synapse",
]
static func run(bug: Bug) async -> IncidentReportPhase {
let fileManager = FileManager.default
guard fileManager.isExecutableFile(atPath: agentBinary.path) else {
return .failed(message: "cursor-agent was not found at \(agentBinary.path).")
}
let scratch = fileManager.temporaryDirectory
.appendingPathComponent("bugger-incident-\(safeToken(bug.id))", isDirectory: true)
defer { try? fileManager.removeItem(at: scratch) }
do {
try fileManager.createDirectory(at: incidentsDirectory, withIntermediateDirectories: true)
try fileManager.createDirectory(at: scratch, withIntermediateDirectories: true)
try IncidentAgentPolicy.write(into: incidentsDirectory)
let images = try await downloadScreenshots(of: bug, into: scratch)
let prompt = makePrompt(bug: bug, images: images)
let output = try await launchAgent(prompt: prompt, images: images.map(\.url))
let report = try extractReport(from: output)
let destination = incidentsDirectory.appendingPathComponent(fileName(for: bug))
try report.write(to: destination, atomically: true, encoding: .utf8)
BuggerLog.info("IncidentAgentRunner: wrote \(destination.path)")
return .succeeded(path: destination.path)
} catch {
BuggerLog.error("IncidentAgentRunner: \(bug.id) \(error.localizedDescription)")
return .failed(message: error.localizedDescription)
}
}
// MARK: - Prompt
private static func makePrompt(bug: Bug, images: [SavedImage]) -> String {
let reportTime = format(bug.createdAt)
let windowStart = format(bug.createdAt.addingTimeInterval(-Double(lookbackDays) * 24 * 60 * 60))
let repos = repositories.map { home.appendingPathComponent($0).path }.joined(separator: "\n")
var lines: [String] = []
lines.append("""
You are doing a read-only incident investigation. You deliver one report and change nothing else.
Hard rules:
- 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 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 ~/.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.
- 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.
Your final message is the report, in markdown, and nothing else. No preamble and no file path.
Time rule:
- The bug report time is the latest moment the incident can have occurred. The incident happened before that, by minutes or by days.
- Read the description and the attached screenshots first (UI clocks, chat timestamps, error text) and infer the incident time. Query logs around that inferred time.
- Do not query logs at or after the report time.
- If you cannot infer a tighter time, search backward from the report time only. Stop at the start of the search window below. Say so in the report.
- If the evidence is not enough for a cause, say what is unknown. Do not invent one.
Search all of these codebases and their logs. Start broad, then follow evidence from one service into another:
\(repos)
Write the report twice, Chinese first and then English. Each language has these sections:
- 时间线 / Timeline
- 根因分析 / Root cause
- 可能的缓解措施 / Possible mitigation
- 长期修复建议 / Long-term fix
Bug:
- Record id: \(bug.id)
- Title: \(bug.title)
- Report time (latest possible incident time): \(reportTime)
- Search window starts: \(windowStart)
- Priority: \(bug.priority.rawValue)
- Status: \(bug.statusRaw ?? bug.status.rawValue)
- Customer: \(bug.customer ?? "(none)")
- Module: \(bug.module ?? "(none)")
- Source: \(bug.source ?? "(none)")
- Reporter: \(bug.reporter ?? "(none)")
- Assignee: \(bug.assignee)
- Feishu: \(bug.feishuURL?.absoluteString ?? "(none)")
""")
if let description = bug.description?.trimmingCharacters(in: .whitespacesAndNewlines), !description.isEmpty {
lines.append("Description:\n\(description)")
} else if bug.title != "Untitled" {
lines.append("Description (问题描述):\n\(bug.title)")
} else {
lines.append("Description: (none)")
}
if let notes = bug.fixNotes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty {
lines.append("Existing fix notes:\n\(notes)")
}
if images.isEmpty {
lines.append("Screenshots: none attached.")
} else {
lines.append("Screenshots are attached. File names:")
lines.append(images.map { "- \($0.url.lastPathComponent)" }.joined(separator: "\n"))
}
return lines.joined(separator: "\n\n")
}
// MARK: - Agent process
private static func launchAgent(prompt: String, images: [URL]) async throws -> AgentOutput {
var arguments = [
"--print",
"--mode", "ask",
"--sandbox", "enabled",
"--trust",
"--approve-mcps",
"--model", "auto",
"--output-format", "json",
"--workspace", incidentsDirectory.path,
]
for relative in repositories {
let url = home.appendingPathComponent(relative, isDirectory: true)
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue {
arguments.append(contentsOf: ["--add-dir", url.path])
}
}
for image in images {
arguments.append(contentsOf: ["--image", image.path])
}
arguments.append(prompt)
let process = Process()
process.executableURL = agentBinary
process.arguments = arguments
process.currentDirectoryURL = incidentsDirectory
var environment = ProcessInfo.processInfo.environment
environment["PATH"] = loginPath()
process.environment = environment
let stdout = PipeBuffer()
let stderr = PipeBuffer()
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
stdout.collect(from: outPipe)
stderr.collect(from: errPipe)
let gate = ResumeGate()
return try await withCheckedThrowingContinuation { continuation in
let timeout = Task {
try? await Task.sleep(nanoseconds: UInt64(runTimeout * 1_000_000_000))
guard !Task.isCancelled, process.isRunning else { return }
process.terminate()
gate.resume { continuation.resume(throwing: RunnerError.timedOut) }
}
process.terminationHandler = { process in
timeout.cancel()
outPipe.fileHandleForReading.readabilityHandler = nil
errPipe.fileHandleForReading.readabilityHandler = nil
let output = AgentOutput(
status: process.terminationStatus,
stdout: stdout.string(),
stderr: stderr.string()
)
gate.resume { continuation.resume(returning: output) }
}
do {
try process.run()
} catch {
timeout.cancel()
gate.resume { continuation.resume(throwing: error) }
}
}
}
private static func extractReport(from output: AgentOutput) throws -> String {
if output.status != 0 {
let detail = output.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
let suffix = detail.isEmpty ? "" : " \(String(detail.suffix(500)))"
throw RunnerError.message("cursor-agent exited \(output.status).\(suffix)")
}
guard let parsed = parseResult(output.stdout) else {
throw RunnerError.message("cursor-agent finished without a report.")
}
if parsed.isError {
let text = parsed.text?.trimmingCharacters(in: .whitespacesAndNewlines)
throw RunnerError.message(text?.isEmpty == false ? text! : "The investigation failed.")
}
guard let text = parsed.text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else {
throw RunnerError.message("cursor-agent finished without a report.")
}
let stamp = format(Date())
return """
<!-- generated by Bugger, read-only investigation, \(stamp) -->
\(unwrapFence(text))
"""
}
private static func parseResult(_ stdout: String) -> (text: String?, isError: Bool)? {
let lines = stdout.split(whereSeparator: \.isNewline)
for line in lines.reversed() {
guard let data = line.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(object["type"] as? String) == "result" else { continue }
let subtype = object["subtype"] as? String
let isError = (object["is_error"] as? Bool) == true || subtype == "error"
return (object["result"] as? String, isError)
}
return nil
}
private static func unwrapFence(_ text: String) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("```") else { return trimmed }
var lines = trimmed.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
guard lines.count >= 2 else { return trimmed }
lines.removeFirst()
if lines.last?.trimmingCharacters(in: .whitespaces) == "```" {
lines.removeLast()
}
return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - Screenshots
private struct SavedImage {
let url: URL
}
private static func downloadScreenshots(of bug: Bug, into directory: URL) async throws -> [SavedImage] {
let images = bug.screenshots.filter(\.isImage).prefix(12)
guard !images.isEmpty else { return [] }
let token = try await TokenManager.shared.getAccessToken()
await BugEditService.shared.ensureFieldsLoaded()
let fieldName = AppStateService.shared.config?.fieldMappings.screenshotsField ?? "相关截图"
let fieldID = await MainActor.run { BugEditService.shared.fieldID(named: fieldName) }
let service = FeishuService()
var saved: [SavedImage] = []
for attachment in images {
do {
let data = try await download(attachment, fieldID: fieldID, token: token, service: service)
let url = directory.appendingPathComponent("\(safeToken(attachment.fileToken))-\(safeFileName(attachment.name))")
try data.write(to: url)
saved.append(SavedImage(url: url))
} catch {
BuggerLog.error("IncidentAgentRunner: screenshot \(attachment.name) failed: \(error.localizedDescription)")
}
}
return saved
}
private static func download(
_ attachment: BugAttachment,
fieldID: String?,
token: String,
service: FeishuService
) async throws -> Data {
do {
return try await service.downloadAttachment(fileToken: attachment.fileToken, accessToken: token)
} catch FeishuError.apiError(let code, _) where code == 400 {
guard let fieldID, let config = AppStateService.shared.config else { throw FeishuError.apiError(code: code, message: "attachment extra unavailable") }
let extra: [String: Any] = [
"bitablePerm": [
"tableId": config.tableId,
"attachments": [fieldID: [attachment.recordId: [attachment.fileToken]]],
],
]
let data = try JSONSerialization.data(withJSONObject: extra)
guard let string = String(data: data, encoding: .utf8) else {
throw FeishuError.invalidConfiguration("Could not build attachment extra parameter.")
}
return try await service.downloadAttachment(
fileToken: attachment.fileToken,
accessToken: token,
extra: string
)
}
}
// MARK: - Helpers
private static func fileName(for bug: Bug) -> String {
let stamp = DateFormatter.incidentFileStamp.string(from: Date())
return "\(stamp)-\(safeToken(bug.id)).md"
}
private static func format(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZZ"
formatter.timeZone = .current
return formatter.string(from: date)
}
private static func loginPath() -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-lc", "print -r -- \"$PATH\""]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
let text = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
if let line = text.split(whereSeparator: \.isNewline).last(where: { $0.contains("/usr/bin") }) {
return String(line)
}
} catch {
BuggerLog.error("IncidentAgentRunner: could not read login PATH: \(error.localizedDescription)")
}
return "/opt/anaconda3/bin:/opt/homebrew/bin:/usr/local/bin:\(home.path)/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
}
private static func safeToken(_ value: String) -> String {
let cleaned = value.filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
return cleaned.isEmpty ? "bug" : cleaned
}
private static func safeFileName(_ name: String) -> String {
let cleaned = name.replacingOccurrences(of: "/", with: "_")
return cleaned.isEmpty ? "image.png" : cleaned
}
}
private struct AgentOutput {
let status: Int32
let stdout: String
let stderr: String
}
private enum RunnerError: LocalizedError {
case timedOut
case message(String)
var errorDescription: String? {
switch self {
case .timedOut:
return "The investigation timed out after 40 minutes."
case .message(let message):
return message
}
}
}
/// Writes the workspace permission file the agent session merges in.
/// 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 {
static func write(into incidentsDirectory: URL) throws {
let directory = incidentsDirectory.appendingPathComponent(".cursor", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let object: [String: Any] = [
"permissions": [
"allow": allow,
"deny": deny,
],
]
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
try data.write(to: directory.appendingPathComponent("cli.json"))
}
private static let grafanaReads = [
"alerting_rules_read", "alerting_silences_read", "analyze_loki_labels",
"check_datasources_health", "diff_tempo_traces", "generate_deeplink",
"get_alert_group", "get_annotation_tags", "get_annotations", "get_assertions",
"get_current_oncall_users", "get_dashboard_by_uid", "get_dashboard_panel_queries",
"get_dashboard_property", "get_dashboard_summary", "get_datasource", "get_doc",
"get_incident", "get_oncall_shift", "get_panel_image", "get_plugin", "get_snapshot",
"get_tempo_trace", "get_tempo_traceql_docs", "list_alert_groups",
"list_dashboard_versions", "list_datasources", "list_incident_custom_fields",
"list_incidents", "list_loki_label_names", "list_loki_label_values",
"list_oncall_schedules", "list_oncall_teams", "list_oncall_users",
"list_prometheus_label_names", "list_prometheus_label_values",
"list_prometheus_metric_metadata", "list_prometheus_metric_names",
"list_provisioning_repositories", "list_pyroscope_label_names",
"list_pyroscope_label_values", "list_pyroscope_profile_types", "list_snapshots",
"list_tempo_attribute_names", "list_tempo_attribute_values", "query_loki_logs",
"query_loki_patterns", "query_loki_stats", "query_prometheus",
"query_prometheus_histogram", "query_pyroscope", "query_tempo_metrics",
"search_dashboards", "search_docs", "search_folders", "search_plugin_information",
"search_tempo_traces", "suggest_loki_alloy_label_config", "user_info",
"validate_provisioning_file",
]
private static let grafanaWrites = [
"add_activity_to_incident", "alerting_manage_routing", "alerting_routing_write",
"alerting_rules_write", "alerting_silences_write", "create_annotation",
"create_datasource", "create_folder", "create_incident", "create_snapshot",
"delete_annotation", "delete_snapshot", "grafana_api_request", "install_plugin",
"update_alert_group", "update_annotation", "update_dashboard", "update_datasource",
"update_incident",
]
private static let allow: [String] =
grafanaReads.map { "Mcp(grafana:\($0))" }
+ ["Mcp(TiDB:db_query)", "Mcp(TiDB:show_databases)", "Mcp(TiDB:show_tables)", "Mcp(TiDB:switch_database)"]
private static let deny: [String] =
["Shell(**)", "Write(**)", "Delete(**)", "Mcp(TiDB:db_execute)", "Mcp(TiDB:db_create_user)", "Mcp(TiDB:db_remove_user)"]
+ grafanaWrites.map { "Mcp(grafana:\($0))" }
}
private final class PipeBuffer: @unchecked Sendable {
private let lock = NSLock()
private var data = Data()
func collect(from pipe: Pipe) {
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let chunk = handle.availableData
if chunk.isEmpty {
handle.readabilityHandler = nil
return
}
self?.append(chunk)
}
}
func append(_ chunk: Data) {
lock.lock()
data.append(chunk)
lock.unlock()
}
func string() -> String {
lock.lock()
defer { lock.unlock() }
return String(data: data, encoding: .utf8) ?? ""
}
}
private final class ResumeGate: @unchecked Sendable {
private let lock = NSLock()
private var resumed = false
func resume(_ body: () -> Void) {
lock.lock()
let already = resumed
resumed = true
lock.unlock()
guard !already else { return }
body()
}
}
private extension DateFormatter {
static let incidentFileStamp: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd-HHmmss"
formatter.timeZone = .current
return formatter
}()
}

View File

@ -1,178 +0,0 @@
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)
}

View File

@ -43,21 +43,6 @@ final class NotificationService: NSObject, UNUserNotificationCenterDelegate {
} }
} }
func deliverNotice(title: String, body: String) async {
await requestAuthorizationIfNeeded()
guard isAuthorized else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(
identifier: "bugger-incident-\(UUID().uuidString)",
content: content,
trigger: nil
)
try? await center.add(request)
}
private func deliver(_ change: BugChange) { private func deliver(_ change: BugChange) {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
switch change.type { switch change.type {

View File

@ -12,7 +12,6 @@ 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
@ -20,25 +19,11 @@ final class PollerService {
private init() {} private init() {}
func startIfConfigured() async { func startIfConfigured() async {
switch await tokenManager.approveKeychainIfNeeded() { guard let config = configService.config,
case .ready: config.isConfigured,
break tokenManager.isAuthenticated else {
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)
} }
@ -46,11 +31,7 @@ final class PollerService {
guard !isRunning else { return } guard !isRunning else { return }
isRunning = true isRunning = true
// Incident reports need a fetch at launch. Otherwise the first fetch if fetchImmediately {
// 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() }
} }
@ -68,26 +49,6 @@ 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
@ -190,21 +151,17 @@ 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)
AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count) AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count)
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,5 +1,3 @@
import AppKit
import CoreGraphics
import Foundation import Foundation
@Observable @Observable
@ -36,53 +34,6 @@ 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,47 +10,30 @@ 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)
delete(key) let query: [String: Any] = [
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
] ]
if let access = accessForAnyApplication() { SecItemDelete(query as CFDictionary)
query[kSecAttrAccess as String] = access SecItemAdd(query as CFDictionary, nil)
}
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 result = copyMatching(key, prompt: false) let query: [String: Any] = [
switch result.status { kSecClass as String: kSecClassGenericPassword,
case errSecSuccess, errSecItemNotFound: kSecAttrService as String: service,
return result.value kSecAttrAccount as String: key,
case errSecInteractionNotAllowed, errSecAuthFailed: kSecReturnData as String: true,
return nil kSecMatchLimit as String: kSecMatchLimitOne
default: ]
BuggerLog.error("Keychain read \(key) failed (\(result.status))") var result: AnyObject?
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) {
@ -61,51 +44,4 @@ 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,41 +5,16 @@ 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

@ -13,7 +13,6 @@ struct BugDetailView: View {
@State private var isUploading = false @State private var isUploading = false
@State private var editError: Error? @State private var editError: Error?
@State private var showNeedsReauth = false @State private var showNeedsReauth = false
@State private var reports = IncidentReportService.shared
private var fixNotesDirty: Bool { private var fixNotesDirty: Bool {
fixNotesDraft != (bug.fixNotes ?? "") fixNotesDraft != (bug.fixNotes ?? "")
@ -24,8 +23,6 @@ struct BugDetailView: View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
header header
Divider() Divider()
incidentSection
Divider()
metadata metadata
if let description = bug.description { if let description = bug.description {
Divider() Divider()
@ -100,76 +97,6 @@ struct BugDetailView: View {
// MARK: - Sections // MARK: - Sections
private var incidentSection: some View {
let phase = reports.phase(for: bug.id)
return VStack(alignment: .leading, spacing: 8) {
Text("Incident report")
.font(.headline)
Text(incidentStatus(phase))
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 12) {
Button(incidentButtonTitle(phase)) {
reports.investigateNow(bug)
}
.disabled(incidentButtonDisabled(phase))
if case .running = phase {
ProgressView()
.controlSize(.small)
}
if case .succeeded(let path) = phase {
Button("Show in Finder") {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
}
}
}
}
}
private func incidentStatus(_ phase: IncidentReportPhase?) -> String {
switch phase {
case nil:
return "Read-only. Writes a Chinese and English report under ~/.bugger/incidents. Does not change code or data."
case .waitingForDetail:
return "Automatic report waits until this bug has a description (问题描述 counts) or a screenshot, then another 15 minutes."
case .scheduled(let fireAt):
return "Automatic report at \(fireAt.formatted(date: .omitted, time: .shortened)). It only reads logs and code."
case .queued:
return "Waiting for the current investigation to finish."
case .running:
return "Investigating. This can take a while. Nothing is edited except the report file Bugger writes at the end."
case .succeeded(let path):
return reports.displayPath(path)
case .failed(let message):
return message
}
}
private func incidentButtonTitle(_ phase: IncidentReportPhase?) -> String {
switch phase {
case .running:
return "Writing report…"
case .queued:
return "Queued"
case .scheduled:
return "Write incident report now"
case .succeeded, .failed:
return "Write incident report again"
case nil, .waitingForDetail:
return "Write incident report"
}
}
private func incidentButtonDisabled(_ phase: IncidentReportPhase?) -> Bool {
switch phase {
case .running, .queued:
return true
default:
return false
}
}
private var header: some View { private var header: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) { HStack(spacing: 8) {

View File

@ -93,15 +93,6 @@ struct SettingsView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
// MARK: Incident reports
settingsSection("Incident reports") {
Toggle("Investigate new bugs", isOn: $config.incidentReportsEnabled)
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)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// MARK: Display // MARK: Display
settingsSection("Display") { settingsSection("Display") {
Toggle("Show floating widget", isOn: $config.showFloatingWidget) Toggle("Show floating widget", isOn: $config.showFloatingWidget)

View File

@ -1,24 +0,0 @@
# 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

@ -337,7 +337,7 @@ def build_pbxproj() -> str:
target_settings = [ target_settings = [
("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"), ("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"),
("COMBINE_HIDPI_IMAGES", "YES"), ("COMBINE_HIDPI_IMAGES", "YES"),
("CURRENT_PROJECT_VERSION", "7"), ("CURRENT_PROJECT_VERSION", "6"),
("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'), ("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'),
("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'), ("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'),
("FEISHU_BASE_DOMAIN", '"$(FEISHU_BASE_DOMAIN)"'), ("FEISHU_BASE_DOMAIN", '"$(FEISHU_BASE_DOMAIN)"'),
@ -346,7 +346,7 @@ def build_pbxproj() -> str:
("LD_RUNPATH_SEARCH_PATHS", ("LD_RUNPATH_SEARCH_PATHS",
'"$(inherited) @executable_path/../Frameworks"'), '"$(inherited) @executable_path/../Frameworks"'),
("MACOSX_DEPLOYMENT_TARGET", "14.0"), ("MACOSX_DEPLOYMENT_TARGET", "14.0"),
("MARKETING_VERSION", "1.7"), ("MARKETING_VERSION", "1.6"),
("PRODUCT_BUNDLE_IDENTIFIER", BUNDLE_ID), ("PRODUCT_BUNDLE_IDENTIFIER", BUNDLE_ID),
("PRODUCT_NAME", '"$(TARGET_NAME)"'), ("PRODUCT_NAME", '"$(TARGET_NAME)"'),
("SWIFT_EMIT_LOC_STRINGS", "YES"), ("SWIFT_EMIT_LOC_STRINGS", "YES"),

View File

@ -1,38 +0,0 @@
# 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): 让新缺陷的事故报告能写出并改存用户目录 — 报告目录、排队条件、启动拉取、钥匙串授权与项目配置