feat(incident): 为新缺陷生成只读事故报告

- 运行中新出现且含描述或截图的缺陷,15 分钟后调用 cursor-agent
- 报告以中英双语写入 incidents 目录,不修改代码和生产数据
- 详情页可手动触发;启动时已有缺陷不自动排队
- 版本号更新至 1.7(build 7)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
tigerenwork 2026-10-24 01:41:08 +08:00
parent 4cf0e9e72c
commit f54f5175c2
9 changed files with 739 additions and 7 deletions

View File

@ -24,6 +24,8 @@
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; };
@ -65,6 +67,8 @@
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>"; };
@ -144,6 +148,8 @@
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 */,
@ -352,6 +358,8 @@
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 */,
@ -405,7 +413,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6; CURRENT_PROJECT_VERSION = 7;
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)";
@ -413,7 +421,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.6; MARKETING_VERSION = 1.7;
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;
@ -426,7 +434,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6; CURRENT_PROJECT_VERSION = 7;
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)";
@ -434,7 +442,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.6; MARKETING_VERSION = 1.7;
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,6 +27,10 @@ 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 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 = "问题描述"
@ -115,7 +119,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 case feishuAppBaseURL, calibrationEnabled, incidentReportsEnabled
} }
init() {} init() {}
@ -134,6 +138,7 @@ 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

@ -0,0 +1,482 @@
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 ~/Dev/aptsell/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("Dev/aptsell/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 ~/Dev/aptsell/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 {
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.
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,
],
"sandbox": [
"mode": "enabled",
"networkAccess": "allow_all",
],
]
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

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

View File

@ -43,6 +43,21 @@ 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

@ -155,6 +155,7 @@ final class PollerService {
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 {
await MainActor.run { await MainActor.run {

View File

@ -13,6 +13,7 @@ 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 ?? "")
@ -23,6 +24,8 @@ 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()
@ -97,6 +100,76 @@ 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 ~/Dev/aptsell/incidents. Does not change code or data."
case .waitingForDetail:
return "Automatic report waits until this bug has a description 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,6 +93,15 @@ 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 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.")
.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

@ -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", "6"), ("CURRENT_PROJECT_VERSION", "7"),
("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.6"), ("MARKETING_VERSION", "1.7"),
("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"),