diff --git a/Bugger.xcodeproj/project.pbxproj b/Bugger.xcodeproj/project.pbxproj index 7f25d28..828c657 100644 --- a/Bugger.xcodeproj/project.pbxproj +++ b/Bugger.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3; }; CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9FBC39502FA635EF1A6A5C; }; 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; }; 56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F4628E7B599B69B499E09E9; }; 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 = ""; }; FE9FBC39502FA635EF1A6A5C /* FeishuModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuModels.swift; sourceTree = ""; }; 21AC33F380B7DC34C39B61A6 /* FeishuService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeishuService.swift; sourceTree = ""; }; + A81026F81FDE9FEE81028963 /* IncidentAgentRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncidentAgentRunner.swift; sourceTree = ""; }; + 980BB4E71F795F8A0A092C1A /* IncidentReportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncidentReportService.swift; sourceTree = ""; }; 59CC175DE8E9FE3250757D33 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 4F4628E7B599B69B499E09E9 /* PollerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollerService.swift; sourceTree = ""; }; 0E7E3775F77DD36C27160A8C /* TokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManager.swift; sourceTree = ""; }; @@ -144,6 +148,8 @@ E834D262A6EE1D5406686761 /* BitableEventService.swift */, D3CA07056AACE5378CF9D421 /* BugEditService.swift */, 52C52B8EDA29A06D9DAA6E5D /* CalibrationService.swift */, + A81026F81FDE9FEE81028963 /* IncidentAgentRunner.swift */, + 980BB4E71F795F8A0A092C1A /* IncidentReportService.swift */, 59CC175DE8E9FE3250757D33 /* NotificationService.swift */, 4F4628E7B599B69B499E09E9 /* PollerService.swift */, 0E7E3775F77DD36C27160A8C /* TokenManager.swift */, @@ -352,6 +358,8 @@ CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */, CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */, 29EE2FE56882F97E14AAF58F /* FeishuService.swift in Sources */, + DB2FC73D43E91946A32816D7 /* IncidentAgentRunner.swift in Sources */, + DC17587DB064B6F31955809D /* IncidentReportService.swift in Sources */, 6148104B311B60C79B3B8572 /* NotificationService.swift in Sources */, 56D44DCBAF4FE0E1D9E620FA /* PollerService.swift in Sources */, B882D310EB2B5D032375CDDF /* TokenManager.swift in Sources */, @@ -405,7 +413,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; FEISHU_APP_ID = "$(FEISHU_APP_ID)"; FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)"; FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)"; @@ -413,7 +421,7 @@ INFOPLIST_FILE = Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -426,7 +434,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; FEISHU_APP_ID = "$(FEISHU_APP_ID)"; FEISHU_APP_SECRET = "$(FEISHU_APP_SECRET)"; FEISHU_BASE_DOMAIN = "$(FEISHU_BASE_DOMAIN)"; @@ -434,7 +442,7 @@ INFOPLIST_FILE = Resources/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.6; + MARKETING_VERSION = 1.7; PRODUCT_BUNDLE_IDENTIFIER = com.xorbitlab.bugger; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Sources/Models/AppConfig.swift b/Sources/Models/AppConfig.swift index 5406075..88d2675 100644 --- a/Sources/Models/AppConfig.swift +++ b/Sources/Models/AppConfig.swift @@ -27,6 +27,10 @@ struct AppConfig: Codable, Equatable { /// only useful when feishuAppBaseURL is set and push reliability is a /// concern. 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 { var titleField: String = "问题描述" @@ -115,7 +119,7 @@ struct AppConfig: Codable, Equatable { case appToken, tableId, assigneeName, fieldMappings case pollIntervalSeconds, dailyRefreshTimes case showFloatingWidget, launchAtLogin, feishuBaseDomain, refreshOnStart - case feishuAppBaseURL, calibrationEnabled + case feishuAppBaseURL, calibrationEnabled, incidentReportsEnabled } init() {} @@ -134,6 +138,7 @@ struct AppConfig: Codable, Equatable { refreshOnStart = try c.decodeIfPresent(Bool.self, forKey: .refreshOnStart) ?? false feishuAppBaseURL = try c.decodeIfPresent(String.self, forKey: .feishuAppBaseURL) ?? "" calibrationEnabled = try c.decodeIfPresent(Bool.self, forKey: .calibrationEnabled) ?? false + incidentReportsEnabled = try c.decodeIfPresent(Bool.self, forKey: .incidentReportsEnabled) ?? true } } diff --git a/Sources/Services/IncidentAgentRunner.swift b/Sources/Services/IncidentAgentRunner.swift new file mode 100644 index 0000000..ed33a2e --- /dev/null +++ b/Sources/Services/IncidentAgentRunner.swift @@ -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 """ + + + \(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 + }() +} diff --git a/Sources/Services/IncidentReportService.swift b/Sources/Services/IncidentReportService.swift new file mode 100644 index 0000000..6bab26e --- /dev/null +++ b/Sources/Services/IncidentReportService.swift @@ -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 = [] + /// 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 = [] + private var timers: [String: Task] = [:] + 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) +} diff --git a/Sources/Services/NotificationService.swift b/Sources/Services/NotificationService.swift index edd1105..c2661a5 100644 --- a/Sources/Services/NotificationService.swift +++ b/Sources/Services/NotificationService.swift @@ -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) { let content = UNMutableNotificationContent() switch change.type { diff --git a/Sources/Services/PollerService.swift b/Sources/Services/PollerService.swift index e9a026f..9a203ee 100644 --- a/Sources/Services/PollerService.swift +++ b/Sources/Services/PollerService.swift @@ -155,6 +155,7 @@ final class PollerService { bugStore.update(with: bugs) bugStore.setLoading(false) AppDelegate.shared?.updateBadge(count: bugStore.activeBugs.count) + IncidentReportService.shared.noteFetch(bugs) } } catch FeishuError.unauthorized { await MainActor.run { diff --git a/Sources/Views/MainWindow/BugDetailView.swift b/Sources/Views/MainWindow/BugDetailView.swift index 3fd9482..88505f7 100644 --- a/Sources/Views/MainWindow/BugDetailView.swift +++ b/Sources/Views/MainWindow/BugDetailView.swift @@ -13,6 +13,7 @@ struct BugDetailView: View { @State private var isUploading = false @State private var editError: Error? @State private var showNeedsReauth = false + @State private var reports = IncidentReportService.shared private var fixNotesDirty: Bool { fixNotesDraft != (bug.fixNotes ?? "") @@ -23,6 +24,8 @@ struct BugDetailView: View { VStack(alignment: .leading, spacing: 20) { header Divider() + incidentSection + Divider() metadata if let description = bug.description { Divider() @@ -97,6 +100,76 @@ struct BugDetailView: View { // 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 { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { diff --git a/Sources/Views/Settings/SettingsView.swift b/Sources/Views/Settings/SettingsView.swift index edc8963..edf31c1 100644 --- a/Sources/Views/Settings/SettingsView.swift +++ b/Sources/Views/Settings/SettingsView.swift @@ -93,6 +93,15 @@ struct SettingsView: View { .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 settingsSection("Display") { Toggle("Show floating widget", isOn: $config.showFloatingWidget) diff --git a/scripts/generate_xcode_project.py b/scripts/generate_xcode_project.py index 837e57c..96317f3 100644 --- a/scripts/generate_xcode_project.py +++ b/scripts/generate_xcode_project.py @@ -337,7 +337,7 @@ def build_pbxproj() -> str: target_settings = [ ("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"), ("COMBINE_HIDPI_IMAGES", "YES"), - ("CURRENT_PROJECT_VERSION", "6"), + ("CURRENT_PROJECT_VERSION", "7"), ("FEISHU_APP_ID", '"$(FEISHU_APP_ID)"'), ("FEISHU_APP_SECRET", '"$(FEISHU_APP_SECRET)"'), ("FEISHU_BASE_DOMAIN", '"$(FEISHU_BASE_DOMAIN)"'), @@ -346,7 +346,7 @@ def build_pbxproj() -> str: ("LD_RUNPATH_SEARCH_PATHS", '"$(inherited) @executable_path/../Frameworks"'), ("MACOSX_DEPLOYMENT_TARGET", "14.0"), - ("MARKETING_VERSION", "1.6"), + ("MARKETING_VERSION", "1.7"), ("PRODUCT_BUNDLE_IDENTIFIER", BUNDLE_ID), ("PRODUCT_NAME", '"$(TARGET_NAME)"'), ("SWIFT_EMIT_LOC_STRINGS", "YES"),