Compare commits
No commits in common. "f3746d092fd1696124bc452e3e3d12e3d60ba4d8" and "4cf0e9e72cb5a55bef9640360a06ab9845f6e569" have entirely different histories.
f3746d092f
...
4cf0e9e72c
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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): 让新缺陷的事故报告能写出并改存用户目录 — 默认目录、问题描述计数、锁屏后继续
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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): 让新缺陷的事故报告能写出并改存用户目录 — 报告目录、排队条件、启动拉取、钥匙串授权与项目配置
|
|
||||||
Loading…
Reference in New Issue