Compare commits
7 Commits
01db8e5347
...
5aef88c6bf
| Author | SHA1 | Date |
|---|---|---|
|
|
5aef88c6bf | |
|
|
88f89cc918 | |
|
|
55d0f86392 | |
|
|
b5ad9c1ec3 | |
|
|
7a2508a449 | |
|
|
a0b1fb8aa3 | |
|
|
be384c9238 |
|
|
@ -32,6 +32,7 @@
|
||||||
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */; };
|
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A83E76FBB83849E4B6704D3 /* FeishuError.swift */; };
|
||||||
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */; };
|
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */; };
|
||||||
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */; };
|
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */; };
|
||||||
|
A1B2C3D4E5F60718293A4B5C /* CalibrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */; };
|
||||||
DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24E1E1D32F7B774D12DD85 /* BugChange.swift */; };
|
DDB9EB345FEBB95DD9976364 /* BugChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B24E1E1D32F7B774D12DD85 /* BugChange.swift */; };
|
||||||
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5 /* BugStatus.swift */; };
|
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646C676356A4125B8E8D5FA5 /* BugStatus.swift */; };
|
||||||
EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */; };
|
EEC5853D00ED565270DC867F /* LocalOAuthServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D11523ACB81DBB9D412995 /* LocalOAuthServer.swift */; };
|
||||||
|
|
@ -69,6 +70,7 @@
|
||||||
EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
|
EF06E1030AA21DA8190169A8 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
|
||||||
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateService.swift; sourceTree = "<group>"; };
|
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateService.swift; sourceTree = "<group>"; };
|
||||||
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitableEventService.swift; sourceTree = "<group>"; };
|
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitableEventService.swift; sourceTree = "<group>"; };
|
||||||
|
1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalibrationService.swift; sourceTree = "<group>"; };
|
||||||
F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.swift"; sourceTree = "<group>"; };
|
F888AA5F655343B19839B4C2 /* URL+Feishu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Feishu.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>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
@ -174,6 +176,7 @@
|
||||||
children = (
|
children = (
|
||||||
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */,
|
F1DD178095EFCE7BC121C1C4 /* AppStateService.swift */,
|
||||||
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */,
|
8F2E3D4C5B6A7988061A2B3C /* BitableEventService.swift */,
|
||||||
|
1A2B3C4D5E6F70819293A4B5 /* CalibrationService.swift */,
|
||||||
59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
|
59CC175DE8E9FE3250757D33 /* NotificationService.swift */,
|
||||||
4F4628E7B599B69B499E09E9 /* PollerService.swift */,
|
4F4628E7B599B69B499E09E9 /* PollerService.swift */,
|
||||||
0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
|
0E7E3775F77DD36C27160A8C /* TokenManager.swift */,
|
||||||
|
|
@ -304,6 +307,7 @@
|
||||||
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */,
|
E4CBAE27491AF1C52944FA91 /* BugStatus.swift in Sources */,
|
||||||
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */,
|
D2855F82E68FADA8BEA43086 /* AppStateService.swift in Sources */,
|
||||||
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */,
|
7E1F2A3B4C5D6E7F8091A2B3 /* BitableEventService.swift in Sources */,
|
||||||
|
A1B2C3D4E5F60718293A4B5C /* CalibrationService.swift in Sources */,
|
||||||
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */,
|
4309E8C70AE99466545A3E37 /* FeishuAuthService.swift in Sources */,
|
||||||
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */,
|
CB529549DB1DBB5F02884300 /* FeishuError.swift in Sources */,
|
||||||
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */,
|
CAE624453A3F0375D872170A /* FeishuModels.swift in Sources */,
|
||||||
|
|
@ -368,7 +372,7 @@
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.3;
|
MARKETING_VERSION = 1.4;
|
||||||
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;
|
||||||
|
|
@ -392,7 +396,7 @@
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.3;
|
MARKETING_VERSION = 1.4;
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.3</string>
|
<string>1.4</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>4</string>
|
<string>4</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
// Real-time change push is non-blocking; no-op if the subscribe
|
// Real-time change push is non-blocking; no-op if the subscribe
|
||||||
// base URL isn't configured (falls back to polling).
|
// base URL isn't configured (falls back to polling).
|
||||||
BitableEventService.shared.connect()
|
BitableEventService.shared.connect()
|
||||||
|
CalibrationService.shared.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,6 @@ struct BuggerApp: App {
|
||||||
Settings {
|
Settings {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentMinSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,22 @@ struct AppConfig: Codable, Equatable {
|
||||||
/// `{feishuAppBaseURL}/api/v1/bitable/events` and refreshes immediately
|
/// `{feishuAppBaseURL}/api/v1/bitable/events` and refreshes immediately
|
||||||
/// on a change push. Empty = disabled (polling only).
|
/// on a change push. Empty = disabled (polling only).
|
||||||
var feishuAppBaseURL: String = ""
|
var feishuAppBaseURL: String = ""
|
||||||
|
/// When true, Bugger periodically queries bugger-feishu for the list of
|
||||||
|
/// record IDs assigned to the current user and triggers a fetchNow() if
|
||||||
|
/// the list doesn't match the bugs in BugStore. Disabled by default —
|
||||||
|
/// only useful when feishuAppBaseURL is set and push reliability is a
|
||||||
|
/// concern.
|
||||||
|
var calibrationEnabled: Bool = false
|
||||||
|
|
||||||
struct FieldMappings: Codable, Equatable {
|
struct FieldMappings: Codable, Equatable {
|
||||||
var titleField: String = "Title"
|
var titleField: String = "问题描述"
|
||||||
var priorityField: String = "Priority"
|
var priorityField: String = "优先级"
|
||||||
var statusField: String = "Status"
|
var statusField: String = "状态"
|
||||||
var assigneeField: String = "Assignee"
|
var assigneeField: String = "负责人"
|
||||||
var reporterField: String = "Reporter"
|
var reporterField: String = "创建人"
|
||||||
var customerField: String = "CustomerName"
|
var customerField: String = "客户名称"
|
||||||
var createdAtField: String = "Created At"
|
var createdAtField: String = "提出时间"
|
||||||
var updatedAtField: String = "Updated At"
|
var updatedAtField: String = "最后更新时间"
|
||||||
|
|
||||||
/// Map Feishu display values to standard BugStatus cases.
|
/// Map Feishu display values to standard BugStatus cases.
|
||||||
/// Keys are the Feishu select-option text; values are the standard status.
|
/// Keys are the Feishu select-option text; values are the standard status.
|
||||||
|
|
@ -65,7 +71,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
|
case feishuAppBaseURL, calibrationEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
@ -83,6 +89,7 @@ struct AppConfig: Codable, Equatable {
|
||||||
feishuBaseDomain = try c.decodeIfPresent(String.self, forKey: .feishuBaseDomain) ?? BundledDefault.feishuBaseDomain
|
feishuBaseDomain = try c.decodeIfPresent(String.self, forKey: .feishuBaseDomain) ?? BundledDefault.feishuBaseDomain
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,35 @@ final class BitableEventService {
|
||||||
/// drop (should auto-reconnect) from an explicit `disconnect()` (should not).
|
/// drop (should auto-reconnect) from an explicit `disconnect()` (should not).
|
||||||
private var isEnabled = false
|
private var isEnabled = false
|
||||||
|
|
||||||
private let reconnectDelay: TimeInterval = 30
|
// -- Exponential backoff with jitter -----------------------------------
|
||||||
|
|
||||||
|
/// Consecutive reconnect attempts since the last successful `connected`
|
||||||
|
/// event. Reset to 0 when the server confirms the stream is alive.
|
||||||
|
private var reconnectAttempt = 0
|
||||||
|
|
||||||
|
/// Base delay (seconds) for the first reconnect attempt.
|
||||||
|
private let reconnectBaseDelay: TimeInterval = 1
|
||||||
|
/// Multiplier applied per attempt.
|
||||||
|
private let reconnectBackoffFactor: Double = 2
|
||||||
|
/// Maximum delay (seconds) — preserves the previous steady-state cadence.
|
||||||
|
private let reconnectMaxDelay: TimeInterval = 30
|
||||||
|
/// Jitter range applied to the computed delay (uniform in [1-range, 1+range]).
|
||||||
|
private let reconnectJitter: Double = 0.25
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
/// Compute the reconnect delay for the current attempt count using
|
||||||
|
/// exponential backoff capped at `reconnectMaxDelay`, then apply ±jitter.
|
||||||
|
/// Formula: delay = min(cap, base * factor^(attempt-1)) * random(1±jitter)
|
||||||
|
private func computeReconnectDelay() -> TimeInterval {
|
||||||
|
let exponent = Double(max(reconnectAttempt, 1) - 1)
|
||||||
|
let raw = min(reconnectMaxDelay, reconnectBaseDelay * pow(reconnectBackoffFactor, exponent))
|
||||||
|
let lo = 1 - reconnectJitter
|
||||||
|
let hi = 1 + reconnectJitter
|
||||||
|
let jitterFactor = Double.random(in: lo...hi)
|
||||||
|
return raw * jitterFactor
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
|
|
||||||
/// Open the SSE connection. Safe to call repeatedly — a no-op if already
|
/// Open the SSE connection. Safe to call repeatedly — a no-op if already
|
||||||
|
|
@ -97,6 +122,7 @@ final class BitableEventService {
|
||||||
session?.invalidateAndCancel()
|
session?.invalidateAndCancel()
|
||||||
session = nil
|
session = nil
|
||||||
delegate = nil
|
delegate = nil
|
||||||
|
reconnectAttempt = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleStreamEnd(error: Error?) {
|
private func handleStreamEnd(error: Error?) {
|
||||||
|
|
@ -113,6 +139,10 @@ final class BitableEventService {
|
||||||
// Only auto-reconnect if the user still wants to be connected.
|
// Only auto-reconnect if the user still wants to be connected.
|
||||||
guard isEnabled else { return }
|
guard isEnabled else { return }
|
||||||
|
|
||||||
|
reconnectAttempt += 1
|
||||||
|
let delay = computeReconnectDelay()
|
||||||
|
BuggerLog.info("BitableEventService: reconnecting in \(String(format: "%.1f", delay))s (attempt \(reconnectAttempt))")
|
||||||
|
|
||||||
let work = DispatchWorkItem { [weak self] in
|
let work = DispatchWorkItem { [weak self] in
|
||||||
guard let self, self.isEnabled else { return }
|
guard let self, self.isEnabled else { return }
|
||||||
guard let url = self.buildURL() else { return }
|
guard let url = self.buildURL() else { return }
|
||||||
|
|
@ -120,7 +150,7 @@ final class BitableEventService {
|
||||||
self.openStream(at: url)
|
self.openStream(at: url)
|
||||||
}
|
}
|
||||||
reconnectWorkItem = work
|
reconnectWorkItem = work
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + reconnectDelay, execute: work)
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleEvent(_ event: String, data: String) {
|
private func handleEvent(_ event: String, data: String) {
|
||||||
|
|
@ -128,9 +158,11 @@ final class BitableEventService {
|
||||||
case "change":
|
case "change":
|
||||||
BuggerLog.info("BitableEventService: change push received (\(data)), refreshing now")
|
BuggerLog.info("BitableEventService: change push received (\(data)), refreshing now")
|
||||||
Task { await PollerService.shared.fetchNow() }
|
Task { await PollerService.shared.fetchNow() }
|
||||||
|
case "connected":
|
||||||
|
reconnectAttempt = 0
|
||||||
case "error":
|
case "error":
|
||||||
BuggerLog.error("BitableEventService: server error \(data)")
|
BuggerLog.error("BitableEventService: server error \(data)")
|
||||||
case "connected", "heartbeat":
|
case "heartbeat":
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
BuggerLog.debug("BitableEventService: unknown event \(event)")
|
BuggerLog.debug("BitableEventService: unknown event \(event)")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Periodically verifies that Bugger's local bug list matches the record IDs
|
||||||
|
/// the notification server (bugger-feishu) believes are assigned to the
|
||||||
|
/// current user. If there's a mismatch — meaning a push was missed — this
|
||||||
|
/// service triggers `PollerService.fetchNow()` to recalibrate.
|
||||||
|
///
|
||||||
|
/// Disabled by default. Enabled via the "Enable calibration check" toggle
|
||||||
|
/// in Advanced settings. Only active when `feishuAppBaseURL` is configured,
|
||||||
|
/// since the calibration endpoint lives on the same server as the SSE stream.
|
||||||
|
final class CalibrationService {
|
||||||
|
static let shared = CalibrationService()
|
||||||
|
|
||||||
|
private var timer: Timer?
|
||||||
|
private let interval: TimeInterval = 300
|
||||||
|
private var isRunning = false
|
||||||
|
private let session = URLSession.shared
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Public
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !isRunning else { return }
|
||||||
|
guard let config = AppStateService.shared.config,
|
||||||
|
config.calibrationEnabled,
|
||||||
|
!config.feishuAppBaseURL.isEmpty else { return }
|
||||||
|
isRunning = true
|
||||||
|
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
|
Task { await self?.calibrate() }
|
||||||
|
}
|
||||||
|
timer?.tolerance = 30
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
|
||||||
|
Task { await self?.calibrate() }
|
||||||
|
}
|
||||||
|
|
||||||
|
BuggerLog.info("CalibrationService: started (interval=\(Int(interval))s)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func restart() {
|
||||||
|
stop()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func calibrate() async {
|
||||||
|
guard let config = AppStateService.shared.config,
|
||||||
|
!config.feishuAppBaseURL.isEmpty,
|
||||||
|
!config.assigneeName.isEmpty,
|
||||||
|
!config.fieldMappings.assigneeField.isEmpty else { return }
|
||||||
|
|
||||||
|
guard let url = buildURL(config: config) else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(from: url)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||||
|
BuggerLog.error("CalibrationService: non-200 response (\((response as? HTTPURLResponse)?.statusCode ?? -1))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = try JSONDecoder().decode(OwnedRecordsResponse.self, from: data)
|
||||||
|
|
||||||
|
guard result.cacheStatus != "cold" else {
|
||||||
|
BuggerLog.info("CalibrationService: server cache cold, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverIDs = Set(result.recordIds)
|
||||||
|
let localIDs = Set(BugStore.shared.bugs.map(\.id))
|
||||||
|
|
||||||
|
guard !localIDs.isEmpty else {
|
||||||
|
BuggerLog.info("CalibrationService: no local bugs yet, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if serverIDs != localIDs {
|
||||||
|
let added = serverIDs.subtracting(localIDs)
|
||||||
|
let removed = localIDs.subtracting(serverIDs)
|
||||||
|
BuggerLog.info("CalibrationService: mismatch (server=\(serverIDs.count), local=\(localIDs.count), +\(added.count) -\(removed.count)), triggering fetchNow")
|
||||||
|
await PollerService.shared.fetchNow()
|
||||||
|
} else {
|
||||||
|
BuggerLog.debug("CalibrationService: in sync (\(serverIDs.count) records)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
BuggerLog.error("CalibrationService: error — \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildURL(config: AppConfig) -> URL? {
|
||||||
|
let base = config.feishuAppBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
guard var comps = URLComponents(string: "\(base)/api/v1/bitable/owned_records") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
comps.queryItems = [
|
||||||
|
URLQueryItem(name: "file_token", value: config.appToken),
|
||||||
|
URLQueryItem(name: "assignee_field", value: config.fieldMappings.assigneeField),
|
||||||
|
URLQueryItem(name: "assignee_name", value: config.assigneeName),
|
||||||
|
]
|
||||||
|
return comps.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Response model
|
||||||
|
|
||||||
|
struct OwnedRecordsResponse: Decodable {
|
||||||
|
let recordIds: [String]
|
||||||
|
let cacheStatus: String
|
||||||
|
let count: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case recordIds = "record_ids"
|
||||||
|
case cacheStatus = "cache_status"
|
||||||
|
case count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,11 @@ final class FeishuAuthService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchCurrentUserName(accessToken: String) async throws -> String {
|
func fetchCurrentUserName(accessToken: String) async throws -> String {
|
||||||
|
let info = try await fetchUserInfo(accessToken: accessToken)
|
||||||
|
return info.name ?? info.enName ?? "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchUserInfo(accessToken: String) async throws -> UserInfoData {
|
||||||
let url = URL(string: "\(baseURL)/authen/v1/user_info")!
|
let url = URL(string: "\(baseURL)/authen/v1/user_info")!
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "GET"
|
request.httpMethod = "GET"
|
||||||
|
|
@ -88,7 +93,7 @@ final class FeishuAuthService {
|
||||||
guard let data = response.data else {
|
guard let data = response.data else {
|
||||||
throw FeishuError.apiError(code: response.code, message: response.msg)
|
throw FeishuError.apiError(code: response.code, message: response.msg)
|
||||||
}
|
}
|
||||||
return data.name ?? data.enName ?? "Unknown"
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tenantAccessToken() async throws -> String {
|
private func tenantAccessToken() async throws -> String {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,9 @@ struct UserInfoData: Decodable {
|
||||||
let name: String?
|
let name: String?
|
||||||
// en_name → enName via .convertFromSnakeCase
|
// en_name → enName via .convertFromSnakeCase
|
||||||
let enName: String?
|
let enName: String?
|
||||||
|
// open_id → openId via .convertFromSnakeCase
|
||||||
|
// Needed for server-side filtering of Person-type fields on the search endpoint.
|
||||||
|
let openId: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum JSONValue: Decodable {
|
enum JSONValue: Decodable {
|
||||||
|
|
@ -93,6 +96,26 @@ enum JSONValue: Decodable {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Display text for Feishu fields whose values are represented as
|
||||||
|
/// structured content, such as text fields (`[{"type": "text", "text": "..."}]`).
|
||||||
|
var textValue: String? {
|
||||||
|
switch self {
|
||||||
|
case let .string(value):
|
||||||
|
return value
|
||||||
|
case let .number(value):
|
||||||
|
return String(value)
|
||||||
|
case let .bool(value):
|
||||||
|
return String(value)
|
||||||
|
case let .array(items):
|
||||||
|
let text = items.compactMap(\.textValue).joined()
|
||||||
|
return text.isEmpty ? nil : text
|
||||||
|
case let .object(fields):
|
||||||
|
return fields["text"]?.textValue ?? fields["name"]?.textValue
|
||||||
|
case .null:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var firstUserName: String? {
|
var firstUserName: String? {
|
||||||
guard case let .array(items) = self else { return nil }
|
guard case let .array(items) = self else { return nil }
|
||||||
for item in items {
|
for item in items {
|
||||||
|
|
@ -139,21 +162,16 @@ enum BugMapper {
|
||||||
case let .number(number): return number
|
case let .number(number): return number
|
||||||
case let .bool(bool): return bool
|
case let .bool(bool): return bool
|
||||||
case let .array(array):
|
case let .array(array):
|
||||||
return array.compactMap { item -> String? in
|
return array.compactMap(\.textValue)
|
||||||
if case let .object(fields) = item {
|
|
||||||
return fields["name"]?.stringValue
|
|
||||||
}
|
|
||||||
return item.stringValue
|
|
||||||
}
|
|
||||||
case let .object(object):
|
case let .object(object):
|
||||||
return object["name"]?.stringValue ?? object["text"]?.stringValue
|
return object["name"]?.textValue ?? object["text"]?.textValue
|
||||||
case .null:
|
case .null:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func stringValue(in fields: [String: JSONValue], key: String) -> String? {
|
private static func stringValue(in fields: [String: JSONValue], key: String) -> String? {
|
||||||
fields[key]?.stringValue
|
fields[key]?.textValue
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func userName(in fields: [String: JSONValue], key: String) -> String? {
|
private static func userName(in fields: [String: JSONValue], key: String) -> String? {
|
||||||
|
|
|
||||||
|
|
@ -15,31 +15,16 @@ final class FeishuService {
|
||||||
func fetchBugs(
|
func fetchBugs(
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
assigneeName: String,
|
assigneeName: String,
|
||||||
accessToken: String
|
accessToken: String,
|
||||||
|
userOpenId: String? = nil
|
||||||
) async throws -> [Bug] {
|
) async throws -> [Bug] {
|
||||||
var allRecords: [RecordItem] = []
|
let records = try await fetchRecordsFiltered(
|
||||||
var pageToken: String?
|
config: config,
|
||||||
var pages = 0
|
assigneeName: assigneeName,
|
||||||
// 25,000 records at 500/page — guard against a runaway cursor that
|
accessToken: accessToken,
|
||||||
// would otherwise loop forever accumulating memory.
|
userOpenId: userOpenId
|
||||||
let maxPages = 50
|
)
|
||||||
|
return records
|
||||||
repeat {
|
|
||||||
pages += 1
|
|
||||||
guard pages <= maxPages else {
|
|
||||||
throw FeishuError.paginationLimitExceeded
|
|
||||||
}
|
|
||||||
let page = try await fetchPage(
|
|
||||||
appToken: config.appToken,
|
|
||||||
tableId: config.tableId,
|
|
||||||
pageToken: pageToken,
|
|
||||||
accessToken: accessToken
|
|
||||||
)
|
|
||||||
allRecords.append(contentsOf: page.items)
|
|
||||||
pageToken = page.hasMore ? page.pageToken : nil
|
|
||||||
} while pageToken != nil
|
|
||||||
|
|
||||||
return allRecords
|
|
||||||
.map { BugMapper.map($0, config: config) }
|
.map { BugMapper.map($0, config: config) }
|
||||||
.filter { bug in
|
.filter { bug in
|
||||||
bug.assignee.localizedCaseInsensitiveContains(assigneeName)
|
bug.assignee.localizedCaseInsensitiveContains(assigneeName)
|
||||||
|
|
@ -61,12 +46,101 @@ final class FeishuService {
|
||||||
return page.total ?? page.items.count
|
return page.total ?? page.items.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Filtered fetch (3-tier fallback)
|
||||||
|
|
||||||
|
/// Fetch records using a 3-tier strategy:
|
||||||
|
/// 1. Search endpoint with open_id filter (Person-type fields, requires OAuth)
|
||||||
|
/// 2. List endpoint with CurrentValue filter (text-type fields)
|
||||||
|
/// 3. Unfiltered list (current behavior — fetch all, filter client-side)
|
||||||
|
private func fetchRecordsFiltered(
|
||||||
|
config: AppConfig,
|
||||||
|
assigneeName: String,
|
||||||
|
accessToken: String,
|
||||||
|
userOpenId: String?
|
||||||
|
) async throws -> [RecordItem] {
|
||||||
|
// Tier 1: search endpoint with open_id (Person fields)
|
||||||
|
if let openId = userOpenId, !openId.isEmpty {
|
||||||
|
do {
|
||||||
|
let records = try await searchRecords(
|
||||||
|
config: config,
|
||||||
|
accessToken: accessToken,
|
||||||
|
assigneeField: config.fieldMappings.assigneeField,
|
||||||
|
openId: openId
|
||||||
|
)
|
||||||
|
if !records.isEmpty {
|
||||||
|
BuggerLog.debug("FeishuService: tier 1 (search by open_id) returned \(records.count) records")
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
BuggerLog.debug("FeishuService: tier 1 returned 0 records, falling back")
|
||||||
|
} catch {
|
||||||
|
BuggerLog.debug("FeishuService: tier 1 failed (\(error.localizedDescription)), falling back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2: list endpoint with CurrentValue filter (text fields)
|
||||||
|
let filterStr = "CurrentValue.[\(config.fieldMappings.assigneeField)]=\"\(assigneeName)\""
|
||||||
|
do {
|
||||||
|
let records = try await listAllRecords(
|
||||||
|
config: config,
|
||||||
|
accessToken: accessToken,
|
||||||
|
filter: filterStr
|
||||||
|
)
|
||||||
|
if !records.isEmpty {
|
||||||
|
BuggerLog.debug("FeishuService: tier 2 (list with filter) returned \(records.count) records")
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
BuggerLog.debug("FeishuService: tier 2 returned 0 records, falling back")
|
||||||
|
} catch {
|
||||||
|
BuggerLog.debug("FeishuService: tier 2 failed (\(error.localizedDescription)), falling back")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: unfiltered list (original behavior)
|
||||||
|
BuggerLog.debug("FeishuService: tier 3 (unfiltered list)")
|
||||||
|
return try await listAllRecords(
|
||||||
|
config: config,
|
||||||
|
accessToken: accessToken,
|
||||||
|
filter: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - List endpoint (GET /records)
|
||||||
|
|
||||||
|
private func listAllRecords(
|
||||||
|
config: AppConfig,
|
||||||
|
accessToken: String,
|
||||||
|
filter: String?
|
||||||
|
) async throws -> [RecordItem] {
|
||||||
|
var allRecords: [RecordItem] = []
|
||||||
|
var pageToken: String?
|
||||||
|
var pages = 0
|
||||||
|
let maxPages = 50
|
||||||
|
|
||||||
|
repeat {
|
||||||
|
pages += 1
|
||||||
|
guard pages <= maxPages else {
|
||||||
|
throw FeishuError.paginationLimitExceeded
|
||||||
|
}
|
||||||
|
let page = try await fetchPage(
|
||||||
|
appToken: config.appToken,
|
||||||
|
tableId: config.tableId,
|
||||||
|
pageToken: pageToken,
|
||||||
|
accessToken: accessToken,
|
||||||
|
filter: filter
|
||||||
|
)
|
||||||
|
allRecords.append(contentsOf: page.items)
|
||||||
|
pageToken = page.hasMore ? page.pageToken : nil
|
||||||
|
} while pageToken != nil
|
||||||
|
|
||||||
|
return allRecords
|
||||||
|
}
|
||||||
|
|
||||||
private func fetchPage(
|
private func fetchPage(
|
||||||
appToken: String,
|
appToken: String,
|
||||||
tableId: String,
|
tableId: String,
|
||||||
pageToken: String?,
|
pageToken: String?,
|
||||||
pageSize: Int = 500,
|
pageSize: Int = 500,
|
||||||
accessToken: String
|
accessToken: String,
|
||||||
|
filter: String? = nil
|
||||||
) async throws -> RecordListData {
|
) async throws -> RecordListData {
|
||||||
guard !appToken.isEmpty, !tableId.isEmpty else {
|
guard !appToken.isEmpty, !tableId.isEmpty else {
|
||||||
throw FeishuError.invalidConfiguration("App token and table ID must not be empty.")
|
throw FeishuError.invalidConfiguration("App token and table ID must not be empty.")
|
||||||
|
|
@ -80,6 +154,9 @@ final class FeishuService {
|
||||||
if let pageToken {
|
if let pageToken {
|
||||||
queryItems.append(URLQueryItem(name: "page_token", value: pageToken))
|
queryItems.append(URLQueryItem(name: "page_token", value: pageToken))
|
||||||
}
|
}
|
||||||
|
if let filter, !filter.isEmpty {
|
||||||
|
queryItems.append(URLQueryItem(name: "filter", value: filter))
|
||||||
|
}
|
||||||
components.queryItems = queryItems
|
components.queryItems = queryItems
|
||||||
|
|
||||||
guard let url = components.url else {
|
guard let url = components.url else {
|
||||||
|
|
@ -117,4 +194,104 @@ final class FeishuService {
|
||||||
}
|
}
|
||||||
return pageData
|
return pageData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Search endpoint (POST /records/search)
|
||||||
|
|
||||||
|
private func searchRecords(
|
||||||
|
config: AppConfig,
|
||||||
|
accessToken: String,
|
||||||
|
assigneeField: String,
|
||||||
|
openId: String
|
||||||
|
) async throws -> [RecordItem] {
|
||||||
|
var allRecords: [RecordItem] = []
|
||||||
|
var pageToken: String?
|
||||||
|
var pages = 0
|
||||||
|
let maxPages = 50
|
||||||
|
|
||||||
|
repeat {
|
||||||
|
pages += 1
|
||||||
|
guard pages <= maxPages else {
|
||||||
|
throw FeishuError.paginationLimitExceeded
|
||||||
|
}
|
||||||
|
let page = try await searchPage(
|
||||||
|
appToken: config.appToken,
|
||||||
|
tableId: config.tableId,
|
||||||
|
pageToken: pageToken,
|
||||||
|
accessToken: accessToken,
|
||||||
|
assigneeField: assigneeField,
|
||||||
|
openId: openId
|
||||||
|
)
|
||||||
|
allRecords.append(contentsOf: page.items)
|
||||||
|
pageToken = page.hasMore ? page.pageToken : nil
|
||||||
|
} while pageToken != nil
|
||||||
|
|
||||||
|
return allRecords
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchPage(
|
||||||
|
appToken: String,
|
||||||
|
tableId: String,
|
||||||
|
pageToken: String?,
|
||||||
|
accessToken: String,
|
||||||
|
assigneeField: String,
|
||||||
|
openId: String
|
||||||
|
) async throws -> RecordListData {
|
||||||
|
guard !appToken.isEmpty, !tableId.isEmpty else {
|
||||||
|
throw FeishuError.invalidConfiguration("App token and table ID must not be empty.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let urlString = "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records/search"
|
||||||
|
guard let url = URL(string: urlString) else {
|
||||||
|
throw FeishuError.invalidConfiguration("Could not build search request URL.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: [String: Any] = [
|
||||||
|
"page_size": 500,
|
||||||
|
"filter": [
|
||||||
|
"conjunction": "and",
|
||||||
|
"conditions": [
|
||||||
|
[
|
||||||
|
"field_name": assigneeField,
|
||||||
|
"operator": "is",
|
||||||
|
"value": [openId],
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
if let pageToken, !pageToken.isEmpty {
|
||||||
|
body["page_token"] = pageToken
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.timeoutInterval = 30
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw FeishuError.networkError(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawBody = String(data: data, encoding: .utf8) ?? "<binary \(data.count)B>"
|
||||||
|
BuggerLog.debug("searchPage: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))")
|
||||||
|
|
||||||
|
if httpResponse.statusCode == 401 {
|
||||||
|
throw FeishuError.unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiResponse: FeishuAPIResponse<RecordListData>
|
||||||
|
do {
|
||||||
|
apiResponse = try decoder.decode(FeishuAPIResponse<RecordListData>.self, from: data)
|
||||||
|
} catch {
|
||||||
|
BuggerLog.error("searchPage: decode FAILED: \(error)")
|
||||||
|
throw FeishuError.decodingError(error, rawBody: String(rawBody.prefix(200)))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard apiResponse.code == 0, let pageData = apiResponse.data else {
|
||||||
|
throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg)
|
||||||
|
}
|
||||||
|
return pageData
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ final class PollerService {
|
||||||
private let configService = AppStateService.shared
|
private let configService = AppStateService.shared
|
||||||
|
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var isFetching = false
|
|
||||||
private var lastFetchTime: Date?
|
private var lastFetchTime: Date?
|
||||||
|
private let fetchGate = FetchGate()
|
||||||
|
|
||||||
private(set) var isRunning = false
|
private(set) var isRunning = false
|
||||||
|
|
||||||
|
|
@ -85,11 +85,53 @@ final class PollerService {
|
||||||
|
|
||||||
// MARK: - Fetch
|
// MARK: - Fetch
|
||||||
|
|
||||||
private func performFetch() async {
|
/// Serializes fetches and coalesces concurrent triggers.
|
||||||
guard !isFetching else { return }
|
///
|
||||||
isFetching = true
|
/// Previously a trigger arriving mid-fetch was silently dropped
|
||||||
defer { isFetching = false }
|
/// (`guard !isFetching else { return }`). Rapid successive changes —
|
||||||
|
/// e.g. an A→B→A reassignment producing two SSE wake-ups — then
|
||||||
|
/// collapsed into a single fetch, and if that fetch sampled the final
|
||||||
|
/// state the transition was never observed and no notification fired.
|
||||||
|
/// Now a trigger arriving mid-fetch queues exactly one follow-up round,
|
||||||
|
/// so the final state is always observed. The actor also makes the
|
||||||
|
/// check-then-set race-safe (triggers arrive from the timer, the SSE
|
||||||
|
/// handler and the UI on different tasks).
|
||||||
|
private actor FetchGate {
|
||||||
|
private var fetching = false
|
||||||
|
private var pending = false
|
||||||
|
|
||||||
|
/// Returns true if the caller may start fetching; otherwise queues
|
||||||
|
/// a follow-up round and returns false.
|
||||||
|
func tryBegin() -> Bool {
|
||||||
|
if fetching {
|
||||||
|
pending = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fetching = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends one round. Returns true when another round was requested
|
||||||
|
/// while fetching; the gate stays held between rounds so no
|
||||||
|
/// parallel fetch can slip in.
|
||||||
|
func endRound() -> Bool {
|
||||||
|
if pending {
|
||||||
|
pending = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
fetching = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performFetch() async {
|
||||||
|
guard await fetchGate.tryBegin() else { return }
|
||||||
|
repeat {
|
||||||
|
await fetchOnce()
|
||||||
|
} while await fetchGate.endRound()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchOnce() async {
|
||||||
lastFetchTime = Date()
|
lastFetchTime = Date()
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|
@ -101,10 +143,12 @@ final class PollerService {
|
||||||
let config = try getConfig()
|
let config = try getConfig()
|
||||||
let token = try await tokenManager.getAccessToken()
|
let token = try await tokenManager.getAccessToken()
|
||||||
let assignee = try await tokenManager.resolveAssigneeName(config: config, accessToken: token)
|
let assignee = try await tokenManager.resolveAssigneeName(config: config, accessToken: token)
|
||||||
|
let openId = tokenManager.cachedOpenId
|
||||||
let bugs = try await feishuService.fetchBugs(
|
let bugs = try await feishuService.fetchBugs(
|
||||||
config: config,
|
config: config,
|
||||||
assigneeName: assignee,
|
assigneeName: assignee,
|
||||||
accessToken: token
|
accessToken: token,
|
||||||
|
userOpenId: openId
|
||||||
)
|
)
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ final class TokenManager {
|
||||||
private let accessTokenKey = "feishu.access_token"
|
private let accessTokenKey = "feishu.access_token"
|
||||||
private let refreshTokenKey = "feishu.refresh_token"
|
private let refreshTokenKey = "feishu.refresh_token"
|
||||||
private let tokenExpiryKey = "feishu.token_expiry"
|
private let tokenExpiryKey = "feishu.token_expiry"
|
||||||
|
private let openIdKey = "feishu.open_id"
|
||||||
|
|
||||||
enum State: Equatable {
|
enum State: Equatable {
|
||||||
case unauthenticated
|
case unauthenticated
|
||||||
|
|
@ -38,6 +39,18 @@ final class TokenManager {
|
||||||
return authService.authorizeURL
|
return authService.authorizeURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user's Feishu open_id, cached after the first OAuth callback or
|
||||||
|
/// user-info fetch. Used for server-side filtering of Person-type fields
|
||||||
|
/// on the Feishu search endpoint. Nil if the user hasn't authenticated.
|
||||||
|
var cachedOpenId: String? {
|
||||||
|
keychain.read(openIdKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeOpenId(_ openId: String) {
|
||||||
|
guard !openId.isEmpty else { return }
|
||||||
|
keychain.write(openId, forKey: openIdKey)
|
||||||
|
}
|
||||||
|
|
||||||
func getAccessToken() async throws -> String {
|
func getAccessToken() async throws -> String {
|
||||||
if let token = keychain.read(accessTokenKey),
|
if let token = keychain.read(accessTokenKey),
|
||||||
let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date,
|
let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date,
|
||||||
|
|
@ -83,6 +96,7 @@ final class TokenManager {
|
||||||
func clearTokens() {
|
func clearTokens() {
|
||||||
keychain.delete(accessTokenKey)
|
keychain.delete(accessTokenKey)
|
||||||
keychain.delete(refreshTokenKey)
|
keychain.delete(refreshTokenKey)
|
||||||
|
keychain.delete(openIdKey)
|
||||||
UserDefaults.standard.removeObject(forKey: tokenExpiryKey)
|
UserDefaults.standard.removeObject(forKey: tokenExpiryKey)
|
||||||
state = .unauthenticated
|
state = .unauthenticated
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +119,11 @@ final class TokenManager {
|
||||||
refresh: response.refreshToken ?? "",
|
refresh: response.refreshToken ?? "",
|
||||||
expiresIn: response.expiresIn
|
expiresIn: response.expiresIn
|
||||||
)
|
)
|
||||||
|
// Fetch and cache the user's open_id for server-side record filtering.
|
||||||
|
if let userInfo = try? await authService.fetchUserInfo(accessToken: response.accessToken),
|
||||||
|
let openId = userInfo.openId {
|
||||||
|
storeOpenId(openId)
|
||||||
|
}
|
||||||
state = .authenticated
|
state = .authenticated
|
||||||
BuggerLog.info("handleCallback: done, authenticated ✓")
|
BuggerLog.info("handleCallback: done, authenticated ✓")
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +132,11 @@ final class TokenManager {
|
||||||
if !config.assigneeName.isEmpty {
|
if !config.assigneeName.isEmpty {
|
||||||
return config.assigneeName
|
return config.assigneeName
|
||||||
}
|
}
|
||||||
return try await authService.fetchCurrentUserName(accessToken: accessToken)
|
let userInfo = try await authService.fetchUserInfo(accessToken: accessToken)
|
||||||
|
if let openId = userInfo.openId {
|
||||||
|
storeOpenId(openId)
|
||||||
|
}
|
||||||
|
return userInfo.name ?? userInfo.enName ?? "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import ServiceManagement
|
import ServiceManagement
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private let kLabelWidth: CGFloat = 130
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@State private var config: AppConfig
|
@State private var config: AppConfig
|
||||||
@State private var isTestingConnection = false
|
@State private var isTestingConnection = false
|
||||||
|
|
@ -13,96 +17,50 @@ struct SettingsView: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 12) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
// Highlighted "Start here" card lives outside the Form so its
|
// MARK: Your Name card
|
||||||
// Spacer + multi-element HStack don't disturb the Form's
|
yourNameCard
|
||||||
// two-column label/control layout (which was pushing the
|
|
||||||
// Feishu Bitable fields off the right edge of the window).
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: "person.fill")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text("Your Name")
|
|
||||||
.font(.headline)
|
|
||||||
TextField("e.g. Alice", text: $config.assigneeName)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
.background(RoundedRectangle(cornerRadius: 8).fill(.tint.opacity(0.08)))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.35), lineWidth: 1))
|
|
||||||
|
|
||||||
Form {
|
// MARK: Feishu Bitable
|
||||||
Section("Feishu Bitable") {
|
settingsSection("Feishu Bitable") {
|
||||||
TextField("App Token (from Bitable URL)", text: $config.appToken)
|
labeledRow("App Token") {
|
||||||
TextField("Table ID", text: $config.tableId)
|
TextField("from Bitable URL", text: $config.appToken)
|
||||||
TextField("Feishu domain", text: $config.feishuBaseDomain)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Button("Test Connection") {
|
|
||||||
testConnection()
|
|
||||||
}
|
|
||||||
.disabled(isTestingConnection || !config.isConfigured)
|
|
||||||
|
|
||||||
if isTestingConnection {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.7)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let connectionResult {
|
|
||||||
Text(connectionResult)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(connectionResult.contains("✓") ? .green : .red)
|
|
||||||
.lineLimit(3)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
labeledRow("Table ID") {
|
||||||
|
TextField("", text: $config.tableId)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
labeledRow("Feishu domain") {
|
||||||
|
TextField("e.g. feishu.cn", text: $config.feishuBaseDomain)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
connectionTestRow
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Polling") {
|
// MARK: Polling
|
||||||
Picker("Check every", selection: $config.pollIntervalSeconds) {
|
settingsSection("Polling") {
|
||||||
Text("1 minute").tag(60)
|
labeledRow("Check every") {
|
||||||
Text("5 minutes").tag(300)
|
Picker("", selection: $config.pollIntervalSeconds) {
|
||||||
Text("10 minutes").tag(600)
|
Text("1 minute").tag(60)
|
||||||
Text("30 minutes").tag(1_800)
|
Text("5 minutes").tag(300)
|
||||||
Text("1 hour").tag(3_600)
|
Text("10 minutes").tag(600)
|
||||||
Text("2 hours").tag(7_200)
|
Text("30 minutes").tag(1_800)
|
||||||
Text("4 hours").tag(14_400)
|
Text("1 hour").tag(3_600)
|
||||||
Text("8 hours").tag(28_800)
|
Text("2 hours").tag(7_200)
|
||||||
Text("12 hours").tag(43_200)
|
Text("4 hours").tag(14_400)
|
||||||
Text("1 day").tag(86_400)
|
Text("8 hours").tag(28_800)
|
||||||
Divider()
|
Text("12 hours").tag(43_200)
|
||||||
Text("Daily schedule").tag(-1)
|
Text("1 day").tag(86_400)
|
||||||
|
Divider()
|
||||||
|
Text("Daily schedule").tag(-1)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.fixedSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.pollIntervalSeconds == -1 {
|
if config.pollIntervalSeconds == -1 {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
dailyScheduleEditor
|
||||||
Text("Refresh at these times:")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
ForEach(dailyTimeBindings.indices, id: \.self) { index in
|
|
||||||
HStack {
|
|
||||||
DatePicker("", selection: dailyTimeBindings[index],
|
|
||||||
displayedComponents: .hourAndMinute)
|
|
||||||
.labelsHidden()
|
|
||||||
|
|
||||||
Button(action: { removeDailyTime(at: index) }) {
|
|
||||||
Image(systemName: "minus.circle.fill")
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(config.dailyRefreshTimes.count <= 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: addDailyTime) {
|
|
||||||
Label("Add time", systemImage: "plus.circle")
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
|
@ -113,19 +71,30 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Real-time updates") {
|
// MARK: Real-time updates
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
settingsSection("Real-time updates") {
|
||||||
TextField("Subscribe base URL", text: $config.feishuAppBaseURL)
|
labeledRow("Subscribe URL") {
|
||||||
|
TextField("e.g. https://example.com", text: $config.feishuAppBaseURL)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
Text("Base URL of the change-notification service, e.g. http://localhost:8000. When set, Bugger listens for change pushes and refreshes immediately — auto-refresh on change is enabled.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
}
|
||||||
|
Text("Base URL of the change-notification service. When set, Bugger listens for change pushes and refreshes immediately — auto-refresh on change is enabled.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Display") {
|
// MARK: Calibration
|
||||||
|
settingsSection("Calibration") {
|
||||||
|
Toggle("Enable calibration check", isOn: $config.calibrationEnabled)
|
||||||
|
Text("Periodically verifies with the notification server that all your assigned bugs are in sync. Helps recover missed push notifications. Only works when a Subscribe base URL is set.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Display
|
||||||
|
settingsSection("Display") {
|
||||||
Toggle("Show floating widget", isOn: $config.showFloatingWidget)
|
Toggle("Show floating widget", isOn: $config.showFloatingWidget)
|
||||||
.onChange(of: config.showFloatingWidget) { _, enabled in
|
.onChange(of: config.showFloatingWidget) { _, enabled in
|
||||||
if enabled {
|
if enabled {
|
||||||
|
|
@ -137,56 +106,207 @@ struct SettingsView: View {
|
||||||
Toggle("Launch at login", isOn: $config.launchAtLogin)
|
Toggle("Launch at login", isOn: $config.launchAtLogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
// MARK: Advanced
|
||||||
DisclosureGroup(isExpanded: $showAdvanced) {
|
advancedSection
|
||||||
Text("Field Mappings")
|
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
TextField("Title field", text: $config.fieldMappings.titleField)
|
// MARK: Actions
|
||||||
TextField("Priority field", text: $config.fieldMappings.priorityField)
|
actionButtons
|
||||||
TextField("Status field", text: $config.fieldMappings.statusField)
|
}
|
||||||
TextField("Assignee field", text: $config.fieldMappings.assigneeField)
|
.frame(maxWidth: 700, alignment: .leading)
|
||||||
TextField("Reporter field", text: $config.fieldMappings.reporterField)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
TextField("Customer field", text: $config.fieldMappings.customerField)
|
.padding(20)
|
||||||
TextField("Created At field", text: $config.fieldMappings.createdAtField)
|
}
|
||||||
TextField("Updated At field", text: $config.fieldMappings.updatedAtField)
|
.frame(minWidth: 520, idealWidth: 560, maxWidth: .infinity,
|
||||||
|
minHeight: 400, idealHeight: 600, maxHeight: .infinity)
|
||||||
Text("Status Mappings")
|
}
|
||||||
.font(.caption)
|
|
||||||
.fontWeight(.semibold)
|
// MARK: - Your Name Card
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("Map your Feishu status values to standard Bugger statuses.")
|
private var yourNameCard: some View {
|
||||||
.font(.caption)
|
HStack(spacing: 12) {
|
||||||
.foregroundStyle(.secondary)
|
Image(systemName: "person.fill")
|
||||||
|
.font(.title2)
|
||||||
ForEach(BugStatus.allCases.filter { $0 != .unknown }, id: \.self) { status in
|
.foregroundStyle(.tint)
|
||||||
statusMappingRow(for: status)
|
Text("Your Name")
|
||||||
}
|
.font(.headline)
|
||||||
} label: {
|
TextField("e.g. Alice", text: $config.assigneeName)
|
||||||
Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3")
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
}
|
.padding(10)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 8).fill(.tint.opacity(0.08)))
|
||||||
HStack {
|
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.tint.opacity(0.35), lineWidth: 1))
|
||||||
Button("Save") {
|
}
|
||||||
save()
|
|
||||||
}
|
// MARK: - Connection Test
|
||||||
.keyboardShortcut(.return)
|
|
||||||
|
private var connectionTestRow: some View {
|
||||||
Button("Disconnect Feishu") {
|
HStack(spacing: 8) {
|
||||||
TokenManager.shared.clearTokens()
|
Button("Test Connection") {
|
||||||
PollerService.shared.stop()
|
testConnection()
|
||||||
BitableEventService.shared.disconnect()
|
}
|
||||||
}
|
.disabled(isTestingConnection || !config.isConfigured)
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
if isTestingConnection {
|
||||||
} // Form
|
ProgressView()
|
||||||
} // VStack
|
.scaleEffect(0.7)
|
||||||
.padding()
|
}
|
||||||
|
|
||||||
|
if let connectionResult {
|
||||||
|
Text(connectionResult)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(connectionResult.contains("✓") ? .green : .red)
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Daily Schedule Editor
|
||||||
|
|
||||||
|
private var dailyScheduleEditor: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Refresh at these times:")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ForEach(dailyTimeBindings.indices, id: \.self) { index in
|
||||||
|
HStack {
|
||||||
|
DatePicker("", selection: dailyTimeBindings[index],
|
||||||
|
displayedComponents: .hourAndMinute)
|
||||||
|
.labelsHidden()
|
||||||
|
|
||||||
|
Button(action: { removeDailyTime(at: index) }) {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(config.dailyRefreshTimes.count <= 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: addDailyTime) {
|
||||||
|
Label("Add time", systemImage: "plus.circle")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.leading, kLabelWidth + 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Advanced Section
|
||||||
|
|
||||||
|
private var advancedSection: some View {
|
||||||
|
DisclosureGroup(isExpanded: $showAdvanced) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
// Field Mappings
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Field Mappings")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
labeledRow("Title field") {
|
||||||
|
TextField("", text: $config.fieldMappings.titleField)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
labeledRow("Priority field") {
|
||||||
|
TextField("", text: $config.fieldMappings.priorityField)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
labeledRow("Status field") {
|
||||||
|
TextField("", text: $config.fieldMappings.statusField)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
labeledRow("Assignee field") {
|
||||||
|
TextField("", text: $config.fieldMappings.assigneeField)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
labeledRow("Reporter field") {
|
||||||
|
TextField("", text: $config.fieldMappings.reporterField)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
labeledRow("Customer field") {
|
||||||
|
TextField("", text: $config.fieldMappings.customerField)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
labeledRow("Created At field") {
|
||||||
|
TextField("", text: $config.fieldMappings.createdAtField)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
labeledRow("Updated At field") {
|
||||||
|
TextField("", text: $config.fieldMappings.updatedAtField)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Status Mappings
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Status Mappings")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Map your Feishu status values to standard Bugger statuses.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ForEach(BugStatus.allCases.filter { $0 != .unknown }, id: \.self) { status in
|
||||||
|
statusMappingRow(for: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
} label: {
|
||||||
|
Label("Advanced: field & status mappings", systemImage: "slider.horizontal.3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Action Buttons
|
||||||
|
|
||||||
|
private var actionButtons: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button("Save") {
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.return)
|
||||||
|
|
||||||
|
Button("Disconnect Feishu") {
|
||||||
|
TokenManager.shared.clearTokens()
|
||||||
|
PollerService.shared.stop()
|
||||||
|
BitableEventService.shared.disconnect()
|
||||||
|
CalibrationService.shared.stop()
|
||||||
|
}
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout Helpers
|
||||||
|
|
||||||
|
/// A consistently-aligned label–control row.
|
||||||
|
private func labeledRow<Content: View>(
|
||||||
|
_ label: String,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text(label)
|
||||||
|
.frame(width: kLabelWidth, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A section with a left-aligned header and divider.
|
||||||
|
private func settingsSection<Content: View>(
|
||||||
|
_ title: String,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Divider()
|
||||||
|
content()
|
||||||
}
|
}
|
||||||
.frame(minWidth: 500, idealWidth: 520, minHeight: 400, maxHeight: 800)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Status Mapping Row
|
// MARK: - Status Mapping Row
|
||||||
|
|
@ -216,8 +336,8 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return VStack(alignment: .leading, spacing: 2) {
|
return VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack(spacing: 6) {
|
||||||
StatusPill(status: status)
|
StatusPill(status: status)
|
||||||
Text(status.label)
|
Text(status.label)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
@ -291,6 +411,7 @@ struct SettingsView: View {
|
||||||
// connect() is a no-op when the URL is empty, so clearing the field
|
// connect() is a no-op when the URL is empty, so clearing the field
|
||||||
// disables real-time push.
|
// disables real-time push.
|
||||||
BitableEventService.shared.reconnect()
|
BitableEventService.shared.reconnect()
|
||||||
|
CalibrationService.shared.restart()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,724 @@
|
||||||
|
# Push Reliability — Design & Implementation Plan
|
||||||
|
|
||||||
|
**Date:** 2026-07-10
|
||||||
|
**Status:** Draft — pending review
|
||||||
|
**Scope:** Bugger (macOS SSE client) + bugger-feishu (k8s SSE server)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
|
||||||
|
Push notifications (SSE `change` events from bugger-feishu → Bugger) can be
|
||||||
|
**delayed or missed entirely**. The root cause is unknown — it could be:
|
||||||
|
|
||||||
|
- **Feishu side** failing to deliver WebSocket events to bugger-feishu.
|
||||||
|
- **bugger-feishu** failing to push the SSE event to the client (e.g. connection
|
||||||
|
dropped, queue overflow, server crash).
|
||||||
|
- **Ingress** (k8s nginx / cloud LB) silently closing the long-lived SSE
|
||||||
|
connection before the heartbeat resets the idle timer (see
|
||||||
|
`SSE_TIMEOUT_ANALYSIS.md`).
|
||||||
|
|
||||||
|
The current reconnection strategy (fixed 30s delay, no jitter) compounds the
|
||||||
|
problem: when the ingress drops many clients simultaneously, they all retry at
|
||||||
|
the same instant — a thundering herd that can overwhelm the ingress and prevent
|
||||||
|
any client from reconnecting.
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
| # | Goal | Owner |
|
||||||
|
|---|------|-------|
|
||||||
|
| 1 | Reconnect with **exponential backoff + jitter** to avoid retry storms | Bugger |
|
||||||
|
| 2 | bugger-feishu exposes an **owned-records endpoint** so Bugger can calibrate | bugger-feishu |
|
||||||
|
| 3 | Bugger has an **advanced settings toggle** (disabled by default) to enable periodic calibration | Bugger |
|
||||||
|
| 4 | Bugger **optimizes the Feishu query** — fetch only the owner's bugs, not all records | Bugger |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Bugger (macOS) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ SSE push ┌──────────────────────────────┐ │
|
||||||
|
│ │ BitableEventSvc │ ◀─────────────│ Ingress → bugger-feishu │ │
|
||||||
|
│ │ (exponential │ change │ (SSE server, 30s heartbeat) │ │
|
||||||
|
│ │ backoff+jitter)│ └──────────────────────────────┘ │
|
||||||
|
│ └────────┬─────────┘ │ │
|
||||||
|
│ │ fetchNow() │ GET /owned_records │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ PollerService │ │ CalibrationService │ │
|
||||||
|
│ │ (periodic poll │ │ (optional, every │ │
|
||||||
|
│ │ + fetchNow) │ │ 5 min, compares │ │
|
||||||
|
│ └────────┬─────────┘ │ record IDs) │ │
|
||||||
|
│ │ └──────────┬───────────┘ │
|
||||||
|
│ ▼ │ mismatch? │
|
||||||
|
│ ┌──────────────────┐ │ │
|
||||||
|
│ │ FeishuService │ ◀─────────────────────┘ fetchNow() │
|
||||||
|
│ │ (search records │ │
|
||||||
|
│ │ with filter) │ │
|
||||||
|
│ └──────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data flow for calibration:**
|
||||||
|
1. `CalibrationService` calls `GET {feishuAppBaseURL}/api/v1/bitable/owned_records`
|
||||||
|
2. bugger-feishu returns the record IDs assigned to this user (from its in-memory cache)
|
||||||
|
3. Bugger compares the returned IDs with `BugStore.shared.bugs.map(\.id)`
|
||||||
|
4. On mismatch → triggers `PollerService.shared.fetchNow()` → `FeishuService` fetches
|
||||||
|
only the owner's bugs (server-side filtered) → `BugStore` updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Design Details
|
||||||
|
|
||||||
|
### 3.1 Item 1 — Exponential Backoff with Jitter
|
||||||
|
|
||||||
|
**Problem:** `BitableEventService.swift:25` uses a fixed `reconnectDelay = 30`.
|
||||||
|
All clients retry on the same cadence, creating synchronized load spikes.
|
||||||
|
|
||||||
|
**Design:**
|
||||||
|
|
||||||
|
Replace the fixed delay with exponential backoff + decorrelated jitter:
|
||||||
|
|
||||||
|
```
|
||||||
|
attempt 1: delay ≈ 1s (±25% jitter)
|
||||||
|
attempt 2: delay ≈ 2s (±25% jitter)
|
||||||
|
attempt 3: delay ≈ 4s (±25% jitter)
|
||||||
|
attempt 4: delay ≈ 8s (±25% jitter)
|
||||||
|
attempt 5: delay ≈ 16s (±25% jitter)
|
||||||
|
attempt 6+: delay ≈ 30s (±25% jitter) ← cap
|
||||||
|
```
|
||||||
|
|
||||||
|
**Formula:**
|
||||||
|
```
|
||||||
|
base = 1.0 (seconds)
|
||||||
|
factor = 2.0
|
||||||
|
cap = 30.0 (seconds) — preserves current steady-state behavior
|
||||||
|
jitter = ±25% (random uniform in [0.75, 1.25])
|
||||||
|
|
||||||
|
raw_delay = min(cap, base * factor^(attempt - 1))
|
||||||
|
delay = raw_delay * Double.random(in: 0.75...1.25)
|
||||||
|
```
|
||||||
|
|
||||||
|
**State management:**
|
||||||
|
- `reconnectAttempt: Int = 0` — incremented on each `handleStreamEnd`, reset to 0
|
||||||
|
when a `connected` event is received from the server.
|
||||||
|
- The attempt counter is an instance variable on `BitableEventService`, mutated
|
||||||
|
only on the main queue (all callbacks are already routed to main).
|
||||||
|
|
||||||
|
**Reset logic:**
|
||||||
|
In `handleEvent`, when event == `"connected"`:
|
||||||
|
```swift
|
||||||
|
reconnectAttempt = 0
|
||||||
|
```
|
||||||
|
This ensures the backoff de-escalates as soon as the connection is healthy.
|
||||||
|
|
||||||
|
**No catch-up fetch on reconnect (unchanged):**
|
||||||
|
The existing `handleEvent` already calls `PollerService.shared.fetchNow()` on
|
||||||
|
`change` events. The calibration service (item 3) provides the safety net for
|
||||||
|
missed events, so we do not add an explicit catch-up fetch on reconnect.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `bugger/Sources/Services/BitableEventService.swift` | Replace `reconnectDelay` constant with backoff logic; add `reconnectAttempt` counter; reset on `connected` event |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Item 2 — bugger-feishu Owned-Records Endpoint
|
||||||
|
|
||||||
|
**Problem:** Bugger has no way to verify it has received all change pushes.
|
||||||
|
If a push is missed (SSE connection down, Feishu WS event lost), the bug list
|
||||||
|
is stale until the next periodic poll.
|
||||||
|
|
||||||
|
**Design:**
|
||||||
|
|
||||||
|
New endpoint on bugger-feishu:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/bitable/owned_records
|
||||||
|
?file_token={app_token}
|
||||||
|
&assignee_field={field_name}
|
||||||
|
&assignee_name={user_name}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_token": "bascnXXX",
|
||||||
|
"assignee_name": "张三",
|
||||||
|
"assignee_field": "负责人",
|
||||||
|
"record_ids": ["recAAA", "recBBB", "recCCC"],
|
||||||
|
"count": 3,
|
||||||
|
"cache_status": "warm",
|
||||||
|
"server_time": 1720000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`cache_status` values:**
|
||||||
|
| Value | Meaning | Bugger action |
|
||||||
|
|-------|---------|---------------|
|
||||||
|
| `"warm"` | Cache has been populated (WS events received or full pull done) | Compare IDs, calibrate on mismatch |
|
||||||
|
| `"cold"` | Cache is empty — no WS events received yet, no full pull done | **Skip calibration** — would cause false positives |
|
||||||
|
| `"stale"` | Cache exists but hasn't been refreshed recently (> 1h since last update) | Compare IDs, but log a warning |
|
||||||
|
|
||||||
|
**Implementation on bugger-feishu:**
|
||||||
|
|
||||||
|
The server already maintains `self._cache: Dict[str, Dict[str, Dict[str, str]]]`
|
||||||
|
(file_token → assignee_field → record_id → assignee_value) in
|
||||||
|
`BitableEventService`. The endpoint reads from this cache — **no Feishu API call**
|
||||||
|
is needed, making it fast and cheap.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# bitable_event_service.py — new method
|
||||||
|
|
||||||
|
def get_owned_record_ids(
|
||||||
|
self, file_token: str, assignee_field: str, assignee_name: str
|
||||||
|
) -> dict:
|
||||||
|
"""Return the record IDs assigned to a user, from the in-memory cache."""
|
||||||
|
file_cache = self._cache.get(file_token, {})
|
||||||
|
field_cache = file_cache.get(assignee_field, {})
|
||||||
|
|
||||||
|
if not field_cache:
|
||||||
|
# Check if we've ever seen this file_token at all
|
||||||
|
if file_token not in self._cache:
|
||||||
|
return {"record_ids": [], "cache_status": "cold", "count": 0}
|
||||||
|
return {"record_ids": [], "cache_status": "cold", "count": 0}
|
||||||
|
|
||||||
|
# Check staleness: if no table_id learned yet, cache may be incomplete
|
||||||
|
has_table_id = file_token in self._table_ids
|
||||||
|
|
||||||
|
owned = [
|
||||||
|
rid for rid, assignee in field_cache.items()
|
||||||
|
if assignee == assignee_name
|
||||||
|
]
|
||||||
|
status = "warm" if has_table_id else "stale"
|
||||||
|
return {
|
||||||
|
"record_ids": owned,
|
||||||
|
"cache_status": status,
|
||||||
|
"count": len(owned),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# bitable_subscription.py — new route
|
||||||
|
|
||||||
|
@router.get("/owned_records")
|
||||||
|
async def owned_records(
|
||||||
|
file_token: str = Query(...),
|
||||||
|
assignee_field: str = Query(...),
|
||||||
|
assignee_name: str = Query(...),
|
||||||
|
):
|
||||||
|
service = getattr(request.app.state, "bitable_event_service", None)
|
||||||
|
if service is None:
|
||||||
|
return {"record_ids": [], "cache_status": "cold", "count": 0}
|
||||||
|
|
||||||
|
result = service.get_owned_record_ids(file_token, assignee_field, assignee_name)
|
||||||
|
result["file_token"] = file_token
|
||||||
|
result["assignee_name"] = assignee_name
|
||||||
|
result["assignee_field"] = assignee_field
|
||||||
|
result["server_time"] = int(time.time())
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why read from cache, not live Feishu API?**
|
||||||
|
- The cache is already maintained and reconciled every 24h by the server.
|
||||||
|
- A live API call per calibration request (every 5 min × N clients) would be
|
||||||
|
expensive and rate-limited.
|
||||||
|
- The cache is "good enough" for calibration — it catches missed pushes, which
|
||||||
|
is the goal. The periodic reconciliation task (every 24h) ensures the cache
|
||||||
|
stays eventually consistent.
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `bugger-feishu/app/services/bitable_event_service.py` | Add `get_owned_record_ids()` method |
|
||||||
|
| `bugger-feishu/app/api/bitable_subscription.py` | Add `GET /owned_records` route |
|
||||||
|
| `bugger-feishu/tests/test_bitable_change_notification.py` | Add unit tests for the endpoint |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Item 3 — Bugger Calibration Service + Advanced Settings Toggle
|
||||||
|
|
||||||
|
**Problem:** Even with backoff, the SSE connection may be unreliable (ingress
|
||||||
|
issues, server restarts). A periodic calibration check provides a safety net.
|
||||||
|
|
||||||
|
**Design:**
|
||||||
|
|
||||||
|
#### 3.3.1 AppConfig — new field
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// AppConfig.swift
|
||||||
|
/// When true, Bugger periodically queries bugger-feishu for the list of
|
||||||
|
/// record IDs assigned to the current user and triggers a fetchNow() if
|
||||||
|
/// the list doesn't match the bugs in BugStore. Disabled by default —
|
||||||
|
/// only useful when feishuAppBaseURL is set and push reliability is a
|
||||||
|
/// concern.
|
||||||
|
var calibrationEnabled: Bool = false
|
||||||
|
```
|
||||||
|
|
||||||
|
- Default: `false` (disabled)
|
||||||
|
- Only meaningful when `feishuAppBaseURL` is set (the calibration endpoint
|
||||||
|
lives on the same server)
|
||||||
|
- Tolerant decoding (like all other AppConfig fields) so existing persisted
|
||||||
|
configs don't break
|
||||||
|
|
||||||
|
#### 3.3.2 CalibrationService — new service
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Sources/Services/CalibrationService.swift
|
||||||
|
|
||||||
|
final class CalibrationService {
|
||||||
|
static let shared = CalibrationService()
|
||||||
|
|
||||||
|
private var timer: Timer?
|
||||||
|
private let interval: TimeInterval = 300 // 5 minutes
|
||||||
|
private var isRunning = false
|
||||||
|
private let session = URLSession.shared
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !isRunning else { return }
|
||||||
|
guard let config = AppStateService.shared.config,
|
||||||
|
config.calibrationEnabled,
|
||||||
|
!config.feishuAppBaseURL.isEmpty else { return }
|
||||||
|
isRunning = true
|
||||||
|
|
||||||
|
// First check after 60s (let the initial fetch + SSE connect settle)
|
||||||
|
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
|
Task { await self?.calibrate() }
|
||||||
|
}
|
||||||
|
timer?.tolerance = 30
|
||||||
|
// Delay the first run
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
|
||||||
|
Task { await self?.calibrate() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
timer?.invalidate()
|
||||||
|
timer = nil
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func restart() {
|
||||||
|
stop()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calibrate() async {
|
||||||
|
guard let config = AppStateService.shared.config,
|
||||||
|
!config.feishuAppBaseURL.isEmpty,
|
||||||
|
!config.assigneeName.isEmpty,
|
||||||
|
!config.fieldMappings.assigneeField.isEmpty else { return }
|
||||||
|
|
||||||
|
// Build URL: {feishuAppBaseURL}/api/v1/bitable/owned_records?...
|
||||||
|
guard let url = buildURL(config: config) else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (data, response) = try await session.data(from: url)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return }
|
||||||
|
|
||||||
|
let result = try JSONDecoder().decode(OwnedRecordsResponse.self, from: data)
|
||||||
|
|
||||||
|
// Skip if cache is cold — would cause false positives
|
||||||
|
guard result.cacheStatus == "warm" || result.cacheStatus == "stale" else { return }
|
||||||
|
|
||||||
|
let serverIDs = Set(result.recordIds)
|
||||||
|
let localIDs = Set(BugStore.shared.bugs.map(\.id))
|
||||||
|
|
||||||
|
// Only calibrate if we have local data (skip on fresh launch
|
||||||
|
// before the first fetch completes)
|
||||||
|
guard !localIDs.isEmpty else { return }
|
||||||
|
|
||||||
|
let mismatch = serverIDs != localIDs
|
||||||
|
if mismatch {
|
||||||
|
BuggerLog.info("CalibrationService: mismatch detected (server=\(serverIDs.count), local=\(localIDs.count)), triggering fetchNow")
|
||||||
|
await PollerService.shared.fetchNow()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
BuggerLog.error("CalibrationService: error — \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildURL(config: AppConfig) -> URL? {
|
||||||
|
let base = config.feishuAppBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
guard var comps = URLComponents(string: "\(base)/api/v1/bitable/owned_records") else { return nil }
|
||||||
|
comps.queryItems = [
|
||||||
|
URLQueryItem(name: "file_token", value: config.appToken),
|
||||||
|
URLQueryItem(name: "assignee_field", value: config.fieldMappings.assigneeField),
|
||||||
|
URLQueryItem(name: "assignee_name", value: config.assigneeName),
|
||||||
|
]
|
||||||
|
return comps.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response model
|
||||||
|
struct OwnedRecordsResponse: Decodable {
|
||||||
|
let recordIds: [String]
|
||||||
|
let cacheStatus: String
|
||||||
|
let count: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case recordIds = "record_ids"
|
||||||
|
case cacheStatus = "cache_status"
|
||||||
|
case count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.3 Settings UI — advanced section
|
||||||
|
|
||||||
|
In `SettingsView.swift`, inside the existing `DisclosureGroup` for Advanced
|
||||||
|
options, add a new section before Field Mappings:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Advanced section — calibration toggle
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Toggle("Enable calibration check", isOn: $config.calibrationEnabled)
|
||||||
|
Text("Periodically verifies with the notification server that all your assigned bugs are in sync. Helps recover missed push notifications. Only works when a Subscribe base URL is set.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.4 Lifecycle wiring
|
||||||
|
|
||||||
|
| Location | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| `AppDelegate.applicationDidFinishLaunching` | After `BitableEventService.shared.connect()`, add `CalibrationService.shared.start()` |
|
||||||
|
| `SettingsView.save()` | After `BitableEventService.shared.reconnect()`, add `CalibrationService.shared.restart()` |
|
||||||
|
| `SettingsView` Disconnect button | Add `CalibrationService.shared.stop()` |
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `bugger/Sources/Models/AppConfig.swift` | Add `calibrationEnabled` field + CodingKey + tolerant decode |
|
||||||
|
| `bugger/Sources/Services/CalibrationService.swift` | **New file** — calibration service |
|
||||||
|
| `bugger/Sources/Views/Settings/SettingsView.swift` | Add toggle in Advanced section; wire lifecycle in `save()` and Disconnect |
|
||||||
|
| `bugger/Sources/AppDelegate.swift` | Start calibration on launch |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 Item 4 — Optimize Feishu Query (Filter by Assignee)
|
||||||
|
|
||||||
|
**Problem:** `FeishuService.fetchBugs()` fetches **ALL** records from the Bitable
|
||||||
|
table (up to 25,000, paginated 500/page = 50 API calls), then filters
|
||||||
|
client-side by assignee name. For large tables, this is slow and wasteful.
|
||||||
|
|
||||||
|
**Feishu API capabilities (verified from official docs):**
|
||||||
|
|
||||||
|
Two approaches are available:
|
||||||
|
|
||||||
|
| Approach | Endpoint | Filter format | Person field support |
|
||||||
|
|----------|----------|---------------|---------------------|
|
||||||
|
| **A. List records** | `GET .../records` | `filter` query param: `CurrentValue.[FieldName]="value"` | Unreliable for Person fields — test code already notes it may not work |
|
||||||
|
| **B. Search records** | `POST .../records/search` | JSON body with structured `filter` object | **Supported** — Person fields require `open_id` (user ID), not display name |
|
||||||
|
|
||||||
|
**Key constraint (from Feishu filter guide):**
|
||||||
|
> Person field value: `["ou_9a971ded01b4ca66f4798549878abcef"]` — Fill in the
|
||||||
|
> corresponding user **ID**. The user ID type must match the `user_id_type`
|
||||||
|
> parameter (default: `open_id`).
|
||||||
|
|
||||||
|
This means filtering by Person fields requires the user's `open_id`, which is
|
||||||
|
only available if the user authenticated via OAuth. If the user entered their
|
||||||
|
name manually in Settings (no OAuth), we cannot filter Person fields server-side.
|
||||||
|
|
||||||
|
**Chosen strategy — multi-tier with fallback:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. If user's open_id is available (from OAuth):
|
||||||
|
→ Use search endpoint with filter: {field_name, operator: "is", value: [open_id]}
|
||||||
|
→ Works for Person-type assignee fields
|
||||||
|
|
||||||
|
2. If open_id not available OR search returns error/0 results:
|
||||||
|
→ Use list endpoint with filter: CurrentValue.[assigneeField]="assigneeName"
|
||||||
|
→ Works for text-type assignee fields
|
||||||
|
|
||||||
|
3. If both fail:
|
||||||
|
→ Fall back to current unfiltered list endpoint (fetch all, filter client-side)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Additionally — `field_names` optimization (applies to all tiers):**
|
||||||
|
|
||||||
|
Both list and search endpoints support requesting only specific fields. By
|
||||||
|
passing only the fields Bugger needs (title, priority, status, assignee, reporter,
|
||||||
|
customer, created_at, updated_at), we reduce the per-record payload significantly
|
||||||
|
— especially in tables with many columns or large text/attachment fields.
|
||||||
|
|
||||||
|
#### 3.4.1 Capture user's open_id during OAuth
|
||||||
|
|
||||||
|
Extend `UserInfoData` to include `openId`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// FeishuModels.swift
|
||||||
|
struct UserInfoData: Decodable {
|
||||||
|
let name: String?
|
||||||
|
let enName: String?
|
||||||
|
let openId: String? // NEW — needed for server-side Person field filtering
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend `TokenManager` to cache the open_id:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// TokenManager.swift
|
||||||
|
private let openIdKey = "feishu.open_id"
|
||||||
|
|
||||||
|
var cachedOpenId: String? {
|
||||||
|
keychain.read(openIdKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In handleCallback() and refreshAccessToken() — after getting user info:
|
||||||
|
// Store open_id alongside tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.4.2 New FeishuService method — search records with filter
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// FeishuService.swift
|
||||||
|
|
||||||
|
func fetchBugs(
|
||||||
|
config: AppConfig,
|
||||||
|
assigneeName: String,
|
||||||
|
accessToken: String,
|
||||||
|
userOpenId: String? = nil // NEW parameter
|
||||||
|
) async throws -> [Bug] {
|
||||||
|
// Try filtered search first, fall back to unfiltered list
|
||||||
|
let records = try await fetchRecordsFiltered(
|
||||||
|
config: config, assigneeName: assigneeName,
|
||||||
|
accessToken: accessToken, userOpenId: userOpenId
|
||||||
|
)
|
||||||
|
return records.map { BugMapper.map($0, config: config) }
|
||||||
|
.filter { bug in bug.assignee.localizedCaseInsensitiveContains(assigneeName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchRecordsFiltered(
|
||||||
|
config: AppConfig, assigneeName: String,
|
||||||
|
accessToken: String, userOpenId: String?
|
||||||
|
) async throws -> [RecordItem] {
|
||||||
|
// Tier 1: search endpoint with open_id filter (Person fields)
|
||||||
|
if let openId = userOpenId {
|
||||||
|
if let records = try? await searchRecords(
|
||||||
|
config: config, accessToken: accessToken,
|
||||||
|
filterConditions: [
|
||||||
|
["field_name": config.fieldMappings.assigneeField,
|
||||||
|
"operator": "is",
|
||||||
|
"value": [openId]]
|
||||||
|
]
|
||||||
|
), !records.isEmpty {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2: list endpoint with CurrentValue filter (text fields)
|
||||||
|
let filterStr = "CurrentValue.[\(config.fieldMappings.assigneeField)]=\"\(assigneeName)\""
|
||||||
|
if let records = try? await listRecords(
|
||||||
|
config: config, accessToken: accessToken, filter: filterStr
|
||||||
|
), !records.isEmpty {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: fallback — unfiltered list (current behavior)
|
||||||
|
return try await listRecords(config: config, accessToken: accessToken, filter: nil)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The client-side `.filter` on `assigneeName` is retained as a safety
|
||||||
|
net even after server-side filtering. It's a no-op if the filter worked
|
||||||
|
correctly, but prevents incorrect records from appearing if the filter is
|
||||||
|
imperfect.
|
||||||
|
|
||||||
|
#### 3.4.3 PollerService — pass open_id to FeishuService
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// PollerService.swift — in performFetch()
|
||||||
|
let openId = TokenManager.shared.cachedOpenId
|
||||||
|
let bugs = try await feishuService.fetchBugs(
|
||||||
|
config: config,
|
||||||
|
assigneeName: assignee,
|
||||||
|
accessToken: token,
|
||||||
|
userOpenId: openId // NEW
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `bugger/Sources/Services/Feishu/FeishuModels.swift` | Add `openId` to `UserInfoData` |
|
||||||
|
| `bugger/Sources/Services/TokenManager.swift` | Cache `open_id` from OAuth; expose `cachedOpenId` |
|
||||||
|
| `bugger/Sources/Services/Feishu/FeishuService.swift` | Add `searchRecords` method; add `fetchRecordsFiltered` with 3-tier fallback; add `field_names` to list calls; accept `userOpenId` parameter |
|
||||||
|
| `bugger/Sources/Services/PollerService.swift` | Pass `userOpenId` to `fetchBugs` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Ingress Considerations
|
||||||
|
|
||||||
|
The user suspects the ingress plays a role in missed pushes. The code changes
|
||||||
|
above make Bugger **resilient** to ingress issues, but the ingress itself should
|
||||||
|
also be configured correctly (per `SSE_TIMEOUT_ANALYSIS.md` R1):
|
||||||
|
|
||||||
|
| Setting | Required value | Why |
|
||||||
|
|---------|---------------|-----|
|
||||||
|
| `proxy_read_timeout` | ≥ 60s | Must exceed the 30s heartbeat with margin |
|
||||||
|
| `proxy_buffering` | `off` | Prevents heartbeats from being buffered and never sent |
|
||||||
|
| `proxy_send_timeout` | ≥ 60s | Symmetric to read timeout |
|
||||||
|
| Cloud LB idle-timeout | ≥ 60s | Some LBs override ingress settings |
|
||||||
|
|
||||||
|
**How the code changes mitigate ingress issues:**
|
||||||
|
- **Backoff + jitter** → When the ingress drops many SSE connections (e.g. on
|
||||||
|
pod restart), clients reconnect at randomized intervals instead of
|
||||||
|
simultaneously, reducing the load spike that could cause the ingress to reject
|
||||||
|
new connections.
|
||||||
|
- **Calibration service** → Even if the SSE connection is chronically
|
||||||
|
unreliable (ingress kills it every cycle), the 5-minute calibration check
|
||||||
|
ensures the bug list stays eventually consistent.
|
||||||
|
- **Optimized Feishu query** → When calibration triggers a `fetchNow()`, the
|
||||||
|
filtered query is much faster and lighter, reducing the load on both the
|
||||||
|
Feishu API and the client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1 — Bugger: Exponential Backoff (Item 1)
|
||||||
|
|
||||||
|
**Scope:** `BitableEventService.swift` only. No server changes. No new files.
|
||||||
|
|
||||||
|
| Step | Task | File |
|
||||||
|
|------|------|------|
|
||||||
|
| 1.1 | Remove `reconnectDelay` constant; add `reconnectAttempt`, `reconnectBaseDelay`, `reconnectMaxDelay`, `reconnectJitterRange` properties | `BitableEventService.swift` |
|
||||||
|
| 1.2 | Add `computeReconnectDelay() -> TimeInterval` method implementing the backoff + jitter formula | `BitableEventService.swift` |
|
||||||
|
| 1.3 | Update `handleStreamEnd` to call `computeReconnectDelay()` and increment `reconnectAttempt` | `BitableEventService.swift` |
|
||||||
|
| 1.4 | Update `handleEvent` to reset `reconnectAttempt = 0` on `"connected"` event | `BitableEventService.swift` |
|
||||||
|
| 1.5 | Reset `reconnectAttempt = 0` in `closeStream()` (clean disconnect resets the counter) | `BitableEventService.swift` |
|
||||||
|
|
||||||
|
**Testing:** Manual — disconnect Wi-Fi, observe reconnect delays in logs.
|
||||||
|
Verify: 1st retry ~1s, 2nd ~2s, 3rd ~4s... capping at ~30s. Reconnect after
|
||||||
|
recovery resets to 1s.
|
||||||
|
|
||||||
|
### Phase 2 — bugger-feishu: Owned-Records Endpoint (Item 2)
|
||||||
|
|
||||||
|
**Scope:** bugger-feishu server only.
|
||||||
|
|
||||||
|
| Step | Task | File |
|
||||||
|
|------|------|------|
|
||||||
|
| 2.1 | Add `get_owned_record_ids()` method to `BitableEventService` | `bitable_event_service.py` |
|
||||||
|
| 2.2 | Add `GET /owned_records` route to `bitable_subscription.py` | `bitable_subscription.py` |
|
||||||
|
| 2.3 | Add unit tests (mock cache warm/cold/stale states) | `test_bitable_change_notification.py` |
|
||||||
|
|
||||||
|
**Testing:** `pytest bugger-feishu/tests/test_bitable_change_notification.py`
|
||||||
|
|
||||||
|
### Phase 3 — Bugger: Calibration Service + Settings (Item 3)
|
||||||
|
|
||||||
|
**Scope:** Bugger client only. Depends on Phase 2 (endpoint must exist).
|
||||||
|
|
||||||
|
| Step | Task | File |
|
||||||
|
|------|------|------|
|
||||||
|
| 3.1 | Add `calibrationEnabled: Bool = false` to `AppConfig` (field + CodingKey + tolerant decode) | `AppConfig.swift` |
|
||||||
|
| 3.2 | Create `CalibrationService.swift` — timer, `calibrate()`, `OwnedRecordsResponse` model | **New file** |
|
||||||
|
| 3.3 | Add calibration toggle in Advanced section of `SettingsView` | `SettingsView.swift` |
|
||||||
|
| 3.4 | Wire `CalibrationService.shared.start()` in `AppDelegate` | `AppDelegate.swift` |
|
||||||
|
| 3.5 | Wire `CalibrationService.shared.restart()` in `SettingsView.save()` | `SettingsView.swift` |
|
||||||
|
| 3.6 | Wire `CalibrationService.shared.stop()` in Disconnect button | `SettingsView.swift` |
|
||||||
|
|
||||||
|
**Testing:** Manual — enable calibration in Advanced settings, verify the timer
|
||||||
|
fires every 5 min (check logs), verify `fetchNow()` triggers on ID mismatch.
|
||||||
|
|
||||||
|
### Phase 4 — Bugger: Optimized Feishu Query (Item 4)
|
||||||
|
|
||||||
|
**Scope:** Bugger client only. Independent of Phases 2-3.
|
||||||
|
|
||||||
|
| Step | Task | File |
|
||||||
|
|------|------|------|
|
||||||
|
| 4.1 | Add `openId` to `UserInfoData` | `FeishuModels.swift` |
|
||||||
|
| 4.2 | Cache `open_id` in `TokenManager` (store on OAuth callback + token refresh) | `TokenManager.swift` |
|
||||||
|
| 4.3 | Add `searchRecords()` method using `POST .../records/search` with structured filter | `FeishuService.swift` |
|
||||||
|
| 4.4 | Add `fetchRecordsFiltered()` with 3-tier fallback (open_id → name → unfiltered) | `FeishuService.swift` |
|
||||||
|
| 4.5 | Add `field_names` parameter to `listRecords`/`searchRecords` to reduce payload | `FeishuService.swift` |
|
||||||
|
| 4.6 | Update `PollerService.performFetch()` to pass `userOpenId` | `PollerService.swift` |
|
||||||
|
|
||||||
|
**Testing:** Manual — verify filtered query returns correct results (check
|
||||||
|
`BuggerLog.debug` output for which tier was used). Test with both Person-type
|
||||||
|
and text-type assignee fields. Verify fallback works when open_id is unavailable.
|
||||||
|
|
||||||
|
### Phase 5 — Verification & Documentation
|
||||||
|
|
||||||
|
| Step | Task |
|
||||||
|
|------|------|
|
||||||
|
| 5.1 | Build Bugger in Xcode — verify no compiler errors |
|
||||||
|
| 5.2 | Run bugger-feishu tests — `pytest` |
|
||||||
|
| 5.3 | End-to-end test: start bugger-feishu → connect Bugger → kill SSE → verify backoff → verify calibration catches missed push |
|
||||||
|
| 5.4 | Update `SSE_TIMEOUT_ANALYSIS.md` — mark R3 (backoff) as implemented, link to this plan |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Key Code Locations (current state)
|
||||||
|
|
||||||
|
All paths relative to `/Users/tigeren/Dev/aptsell/bugger-boundle/`:
|
||||||
|
|
||||||
|
### Bugger (macOS client)
|
||||||
|
|
||||||
|
| Location | What |
|
||||||
|
|----------|------|
|
||||||
|
| `bugger/Sources/Services/BitableEventService.swift:25` | `reconnectDelay = 30` (fixed — to be replaced with backoff) |
|
||||||
|
| `bugger/Sources/Services/BitableEventService.swift:102-124` | `handleStreamEnd` — reconnect scheduling |
|
||||||
|
| `bugger/Sources/Services/BitableEventService.swift:126-138` | `handleEvent` — `connected` event (no-op — to reset attempt counter) |
|
||||||
|
| `bugger/Sources/Services/BitableEventService.swift:140-165` | `buildURL` — SSE endpoint URL construction |
|
||||||
|
| `bugger/Sources/Services/PollerService.swift:88-126` | `performFetch` — calls `FeishuService.fetchBugs` |
|
||||||
|
| `bugger/Sources/Services/Feishu/FeishuService.swift:15-47` | `fetchBugs` — fetches ALL records, filters client-side |
|
||||||
|
| `bugger/Sources/Services/Feishu/FeishuService.swift:64-119` | `fetchPage` — list records API call |
|
||||||
|
| `bugger/Sources/Services/Feishu/FeishuModels.swift:54-58` | `UserInfoData` — has `name`, `enName` (needs `openId`) |
|
||||||
|
| `bugger/Sources/Services/TokenManager.swift:112-117` | `resolveAssigneeName` — returns name, not open_id |
|
||||||
|
| `bugger/Sources/Models/AppConfig.swift:3-87` | `AppConfig` — needs `calibrationEnabled` field |
|
||||||
|
| `bugger/Sources/Views/Settings/SettingsView.swift:140-170` | Advanced section — needs calibration toggle |
|
||||||
|
| `bugger/Sources/AppDelegate.swift:29-34` | App launch — needs `CalibrationService.start()` |
|
||||||
|
|
||||||
|
### bugger-feishu (SSE server)
|
||||||
|
|
||||||
|
| Location | What |
|
||||||
|
|----------|------|
|
||||||
|
| `bugger-feishu/app/api/bitable_subscription.py:24-94` | SSE `/events` endpoint — model for new `/owned_records` route |
|
||||||
|
| `bugger-feishu/app/services/bitable_event_service.py:49-66` | `BitableEventService` — maintains `_cache` (record_id → assignee) |
|
||||||
|
| `bugger-feishu/app/services/bitable_event_service.py:124-182` | `warm_cache` — full pull that populates the cache |
|
||||||
|
| `bugger-feishu/app/services/bitable_event_service.py:215-263` | `add_connection` / `remove_connection` — SSE lifecycle |
|
||||||
|
| `bugger-feishu/app/services/bitable_event_service.py:460-525` | Reconciliation task — periodic full pull (24h) |
|
||||||
|
| `bugger-feishu/app/main.py:88-91` | Router registration — add `/owned_records` route here |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|------|--------|------------|
|
||||||
|
| Feishu search endpoint filter doesn't work for Person fields with open_id | Filtered query returns 0 results → fallback to unfiltered (current behavior) | 3-tier fallback strategy ensures no regression |
|
||||||
|
| Calibration false positives (server cache stale, triggers unnecessary fetchNow) | Extra Feishu API calls every 5 min | `cache_status` check — skip when cold; `localIDs.isEmpty` check — skip on fresh launch; 5-min interval limits impact |
|
||||||
|
| Server cache doesn't include newly assigned records (reconciliation runs every 24h) | Calibration misses recent assignments | The SSE push should catch these; calibration is a safety net, not the primary mechanism. Server reconciliation interval can be reduced if needed. |
|
||||||
|
| Backoff delays push recovery (30s cap vs. current 30s fixed) | Slightly slower first reconnect (1s vs 30s — actually faster!) | Backoff starts at 1s (faster than current 30s), caps at 30s. Net improvement. |
|
||||||
|
| `open_id` not available (user entered name manually, no OAuth) | Can't filter Person fields server-side | Fall back to text filter or unfiltered list. No regression. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open Questions
|
||||||
|
|
||||||
|
1. **Calibration interval** — 5 min is proposed. Should it be configurable in
|
||||||
|
settings, or fixed? (Recommend: fixed for simplicity; can add a setting
|
||||||
|
later if needed.)
|
||||||
|
|
||||||
|
2. **Server cache freshness** — The server's reconciliation runs every 24h.
|
||||||
|
Should the calibration endpoint trigger a cache refresh if the cache is
|
||||||
|
stale? (Recommend: no — keep the endpoint read-only and cheap. The
|
||||||
|
reconciliation task handles freshness.)
|
||||||
|
|
||||||
|
3. **Person field filter with `contains` operator** — Could we use
|
||||||
|
`operator: "contains"` with the user's name instead of `is` with open_id?
|
||||||
|
The Feishu docs say Person field values must be user IDs, but `contains`
|
||||||
|
might behave differently. (Recommend: test during implementation; if
|
||||||
|
`contains` with name works, it simplifies the flow by not requiring open_id.)
|
||||||
|
|
||||||
|
4. **`field_names` URL encoding** — The list endpoint expects `field_names` as
|
||||||
|
a JSON array string (e.g. `["Title","Status"]`). Need to verify that
|
||||||
|
`URLQueryItem` encodes this correctly for the Feishu API.
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
# SSE Long-Lived Connection — Timeout & Reconnection Analysis
|
||||||
|
|
||||||
|
**Date:** 2026-07-10
|
||||||
|
**Status:** Analysis complete — recommendations pending implementation
|
||||||
|
**Scope:** Bugger (macOS SSE client) → Ingress → bugger-feishu (k8s pod, SSE server)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Bugger subscribes to bugger-feishu via a long-lived SSE connection that traverses a
|
||||||
|
Kubernetes ingress. The connection's survival depends on **server-side heartbeats** and
|
||||||
|
the **ingress idle-timeout configuration** — neither of which Bugger can control from the
|
||||||
|
client side.
|
||||||
|
|
||||||
|
| Concern | Finding | Risk |
|
||||||
|
|---|---|---|
|
||||||
|
| Heartbeat | Server emits `: heartbeat\n\n` every ~30s; client relies on it | ✅ if ingress idle-timeout ≥ 60s; ❌ if tighter |
|
||||||
|
| Reconnection | Present, fixed 30s delay, well-guarded | Works, but no backoff/jitter → retry storms |
|
||||||
|
| Exponential backoff | **Not implemented** | Retry storms under sustained outage |
|
||||||
|
|
||||||
|
**Top recommendations:**
|
||||||
|
1. Set ingress `proxy_read_timeout` / LB idle-timeout to **≥ 60s** with `proxy_buffering off`.
|
||||||
|
2. Verify the 30s heartbeat is actually deployed in bugger-feishu (it is only *specified* in
|
||||||
|
this repo's docs, not in shipped code).
|
||||||
|
3. Add exponential backoff with jitter and a cap to `BitableEventService` reconnect logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture Under Test
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ SSE (outbound, long-lived) ┌─────────────────────────────┐
|
||||||
|
│ Bugger │ ──────────────────────────────▶ │ Kubernetes Ingress │
|
||||||
|
│ (macOS) │ GET /api/v1/bitable/events │ (idle-timeout = T_ingress) │
|
||||||
|
│ SSE client │ Accept: text/event-stream │ │ │
|
||||||
|
└──────────────┘ │ ▼ │
|
||||||
|
│ ┌────────────────────────┐ │
|
||||||
|
│ │ bugger-feishu pod │ │
|
||||||
|
│ │ (FastAPI SSE server) │ │
|
||||||
|
│ │ heartbeat every 30s │ │
|
||||||
|
│ └────────────────────────┘ │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Bugger** is the SSE *client* (`Sources/Services/BitableEventService.swift`, 227 LOC).
|
||||||
|
- **bugger-feishu** is the SSE *server* — a separate FastAPI service deployed as a k8s pod.
|
||||||
|
- The ingress sits between them and **will close any connection that appears idle** for
|
||||||
|
longer than its configured idle-timeout (`proxy_read_timeout` for nginx ingress, default
|
||||||
|
60s; cloud LBs vary).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Heartbeat Analysis
|
||||||
|
|
||||||
|
### 2.1 Client side — relies on server, sends nothing
|
||||||
|
|
||||||
|
SSE is a one-way protocol (server → client). Bugger's client **cannot keep the connection
|
||||||
|
alive from its end** — it can only receive. It depends entirely on the server periodically
|
||||||
|
sending bytes to reset the ingress's idle timer.
|
||||||
|
|
||||||
|
Client-side idle-timeout configuration (`BitableEventService.swift:61-66`):
|
||||||
|
```swift
|
||||||
|
let config = URLSessionConfiguration.ephemeral
|
||||||
|
// The server sends a heartbeat every ~30s, so 5 min of silence is a
|
||||||
|
// safe "something went wrong" threshold.
|
||||||
|
config.timeoutIntervalForRequest = 300
|
||||||
|
config.timeoutIntervalForResource = .infinity
|
||||||
|
config.waitsForConnectivity = true
|
||||||
|
```
|
||||||
|
- `timeoutIntervalForRequest = 300` (5 min): the client's own dead-connection detector.
|
||||||
|
Set generously above the 30s server heartbeat so it never fires under normal operation.
|
||||||
|
- `timeoutIntervalForResource = .infinity`: unbounded resource lifetime (correct for a stream).
|
||||||
|
- `waitsForConnectivity = true`: OS waits through transient network gaps rather than failing.
|
||||||
|
|
||||||
|
The client's 300s timeout is **never the bottleneck** — it is the ingress that will drop
|
||||||
|
the connection first.
|
||||||
|
|
||||||
|
### 2.2 Server side — specified, must be verified deployed
|
||||||
|
|
||||||
|
The heartbeat is specified in `docs/BITABLE_CHANGE_NOTIFICATION_IMPL.md:686-693`:
|
||||||
|
```python
|
||||||
|
# Wait for events with heartbeat timeout
|
||||||
|
event = await asyncio.wait_for(conn.queue.get(), timeout=30.0)
|
||||||
|
yield f"event: {event['event']}\ndata: {event['data']}\n\n"
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Heartbeat
|
||||||
|
yield ": heartbeat\n\n"
|
||||||
|
```
|
||||||
|
- **Interval: 30 seconds.** Emits an SSE comment frame (`: heartbeat\n\n`) when no real
|
||||||
|
event is queued within 30s.
|
||||||
|
- Response headers include `Connection: keep-alive` and `X-Accel-Buffering: no`
|
||||||
|
(lines 707-711) to prevent buffering proxies from holding the heartbeat.
|
||||||
|
|
||||||
|
⚠️ **This code lives in the separate bugger-feishu repo and is only *specified* in this
|
||||||
|
repo's docs.** Whether it is actually deployed must be verified in the bugger-feishu repo
|
||||||
|
before relying on it.
|
||||||
|
|
||||||
|
### 2.3 Will the heartbeat prevent ingress disconnect?
|
||||||
|
|
||||||
|
The ingress closes an upstream connection when **no bytes flow** for its idle-timeout
|
||||||
|
period. The heartbeat resets that timer each time it is sent.
|
||||||
|
|
||||||
|
| Ingress idle-timeout | Heartbeat (30s) prevents disconnect? |
|
||||||
|
|---|---|
|
||||||
|
| ≥ 60s (nginx ingress default) | ✅ Yes — 30s < 60s, timer resets each cycle |
|
||||||
|
| 30s | ⚠️ Marginal — race conditions; not safe |
|
||||||
|
| < 30s (some cloud LBs) | ❌ No — connection dropped every cycle |
|
||||||
|
|
||||||
|
**Requirements for the heartbeat to work:**
|
||||||
|
1. Ingress `proxy_read_timeout` / LB idle-timeout **≥ 60s** (30s heartbeat + safety margin).
|
||||||
|
2. `proxy_buffering off` on the ingress location, **or** the server's `X-Accel-Buffering: no`
|
||||||
|
header must be honored — otherwise heartbeats are buffered and never reach the client
|
||||||
|
until the response completes (which never happens for a stream).
|
||||||
|
3. The 30s heartbeat must actually be running in the deployed bugger-feishu pod.
|
||||||
|
|
||||||
|
### 2.4 What happens on a too-tight ingress timeout?
|
||||||
|
|
||||||
|
If the ingress idle-timeout is, say, 15s:
|
||||||
|
1. Bugger connects.
|
||||||
|
2. After 15s of silence (before the 30s heartbeat fires), the ingress closes the upstream.
|
||||||
|
3. The client sees the stream end → schedules a reconnect in 30s.
|
||||||
|
4. Repeat every ~45s indefinitely — a steady-state reconnect loop.
|
||||||
|
|
||||||
|
This wastes bandwidth, defeats the push model, and — with many Bugger clients — creates a
|
||||||
|
synchronized reconnect storm (no jitter, see §3.3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Reconnection Analysis
|
||||||
|
|
||||||
|
### 3.1 Mechanism — present and well-guarded
|
||||||
|
|
||||||
|
Reconnection is implemented in `BitableEventService.swift`. On any stream end (error or
|
||||||
|
clean), if the user still intends to be connected, a reconnect is scheduled.
|
||||||
|
|
||||||
|
State guard (`BitableEventService.swift:21-23`):
|
||||||
|
```swift
|
||||||
|
/// The user's intent to be connected. Distinguishes an unexpected stream
|
||||||
|
/// drop (should auto-reconnect) from an explicit `disconnect()` (should not).
|
||||||
|
private var isEnabled = false
|
||||||
|
```
|
||||||
|
|
||||||
|
Reconnect scheduling (`BitableEventService.swift:102-124`):
|
||||||
|
```swift
|
||||||
|
private func handleStreamEnd(error: Error?) {
|
||||||
|
task = nil
|
||||||
|
session = nil
|
||||||
|
delegate = nil
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
BuggerLog.error("BitableEventService: stream ended (\(error.localizedDescription))")
|
||||||
|
} else {
|
||||||
|
BuggerLog.info("BitableEventService: stream ended")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only auto-reconnect if the user still wants to be connected.
|
||||||
|
guard isEnabled else { return }
|
||||||
|
|
||||||
|
let work = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self, self.isEnabled else { return }
|
||||||
|
guard let url = self.buildURL() else { return }
|
||||||
|
BuggerLog.info("BitableEventService: reconnecting...")
|
||||||
|
self.openStream(at: url)
|
||||||
|
}
|
||||||
|
reconnectWorkItem = work
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + reconnectDelay, execute: work)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixed delay constant (`BitableEventService.swift:25`):
|
||||||
|
```swift
|
||||||
|
private let reconnectDelay: TimeInterval = 30
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard quality — good:**
|
||||||
|
- Double-guarded against intentional disconnect: `isEnabled` check at line 114, plus
|
||||||
|
re-checked inside the `DispatchWorkItem` at line 117.
|
||||||
|
- Pending reconnect is cancellable: `closeStream()` calls `reconnectWorkItem?.cancel()`
|
||||||
|
(line 93), so `disconnect()` cannot race with a scheduled reconnect.
|
||||||
|
- All state mutation happens on the main queue (callbacks routed via `DispatchQueue.main.async`
|
||||||
|
in `SSESessionDelegate` at lines 202, 223).
|
||||||
|
|
||||||
|
**Gap — no catch-up fetch:** reconnection does **not** trigger an immediate `fetchNow()`
|
||||||
|
to recover events missed during the downtime window. It relies on the next server push or
|
||||||
|
the periodic `PollerService` to eventually resync. This is acceptable given polling is the
|
||||||
|
fallback, but means event freshness is bounded by the poll interval after any reconnect.
|
||||||
|
|
||||||
|
### 3.2 Exponential backoff — NOT implemented
|
||||||
|
|
||||||
|
The reconnect delay is a **constant 30s, every time, forever**, regardless of how many
|
||||||
|
consecutive failures occur.
|
||||||
|
|
||||||
|
Searching the repo, "exponential backoff" appears only in design docs for the *rejected*
|
||||||
|
direct-WebSocket design — never in shipped SSE code:
|
||||||
|
- `docs/TECH_INVESTIGATION.md:56-58` — Feishu raw WebSocket option (not chosen)
|
||||||
|
- `docs/TECH_INVESTIGATION.md:424` — planned task for the abandoned WSClient approach
|
||||||
|
- `docs/TECH_INVESTIGATION.md:544` — same
|
||||||
|
|
||||||
|
### 3.3 Risks of the fixed-delay strategy
|
||||||
|
|
||||||
|
| Scenario | Behavior | Problem |
|
||||||
|
|---|---|---|
|
||||||
|
| bugger-feishu pod down 1 hour | 120 reconnect attempts at fixed 30s | No de-escalation; wastes resources |
|
||||||
|
| bugger-feishu pod restarts | All clients reconnect simultaneously after 30s | **Thundering herd** — no jitter |
|
||||||
|
| Ingress drops every cycle (timeout < 30s) | Reconnect every ~45s per client | Steady-state storm across all clients |
|
||||||
|
| Many clients, transient outage | All retry on identical 30s cadence | Synchronized load spikes on ingress |
|
||||||
|
|
||||||
|
The lack of jitter is the most operationally significant gap: with N Bugger clients all
|
||||||
|
using the same fixed 30s delay, a pod restart causes all N to hit the ingress at the same
|
||||||
|
instant, every 30s, until one succeeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Findings Summary
|
||||||
|
|
||||||
|
### Q1 — Heartbeat & ingress timeout
|
||||||
|
|
||||||
|
- **Yes**, a heartbeat exists — but it is **server-side only** (bugger-feishu emits
|
||||||
|
`: heartbeat\n\n` every ~30s). Bugger's client sends nothing.
|
||||||
|
- **Will it prevent ingress disconnect?** Only if the ingress idle-timeout
|
||||||
|
(`proxy_read_timeout` / LB idle-timeout) is **≥ the heartbeat interval (30s)**, with
|
||||||
|
margin — recommend **≥ 60s**. It also requires `proxy_buffering off` (or honored
|
||||||
|
`X-Accel-Buffering: no`) so heartbeats are not buffered.
|
||||||
|
- **Caveat:** the heartbeat code lives in the separate bugger-feishu repo. It is only
|
||||||
|
*specified* in this repo's docs — verify it is actually deployed.
|
||||||
|
|
||||||
|
### Q2 — Reconnection & exponential backoff
|
||||||
|
|
||||||
|
- **Yes**, reconnection exists. It is triggered on stream end when `isEnabled == true`,
|
||||||
|
uses a fixed **30s delay**, and is properly guarded against intentional disconnects
|
||||||
|
(double guard + cancellable work item).
|
||||||
|
- **No**, it does **not** use exponential backoff. The delay is constant 30s, indefinitely,
|
||||||
|
with no cap, no jitter, and no escalation/de-escalation. This creates retry-storm and
|
||||||
|
thundering-herd risks under sustained or synchronized outages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Recommendations
|
||||||
|
|
||||||
|
### R1 — Ingress / LB configuration (ops, no code change)
|
||||||
|
|
||||||
|
Configure the ingress in front of bugger-feishu:
|
||||||
|
1. `proxy_read_timeout 60s;` (or higher) — must exceed the 30s heartbeat with margin.
|
||||||
|
2. `proxy_buffering off;` on the SSE location — or ensure `X-Accel-Buffering: no` is honored.
|
||||||
|
3. `proxy_send_timeout 60s;` — symmetric.
|
||||||
|
4. Verify the cloud LB idle-timeout (if any, in front of the ingress) is also ≥ 60s — some
|
||||||
|
LBs default to 30s or less and override the ingress setting.
|
||||||
|
|
||||||
|
### R2 — Verify heartbeat is deployed (ops, no code change)
|
||||||
|
|
||||||
|
Confirm in the bugger-feishu repo that the 30s `: heartbeat\n\n` emission is actually
|
||||||
|
running in the deployed image, not just specified in this repo's docs. If absent, the
|
||||||
|
ingress will drop the connection on every idle cycle regardless of R1.
|
||||||
|
|
||||||
|
### R3 — Add exponential backoff with jitter and cap (code change)
|
||||||
|
|
||||||
|
Modify `BitableEventService.swift` to replace the fixed `reconnectDelay` with a backoff
|
||||||
|
strategy. Suggested parameters:
|
||||||
|
|
||||||
|
```
|
||||||
|
attempt 1: delay = 1s (±20% jitter)
|
||||||
|
attempt 2: delay = 2s (±20% jitter)
|
||||||
|
attempt 3: delay = 4s (±20% jitter)
|
||||||
|
attempt 4: delay = 8s (±20% jitter)
|
||||||
|
attempt 5: delay = 16s (±20% jitter)
|
||||||
|
attempt 6+: delay = 30s (±20% jitter) ← cap
|
||||||
|
```
|
||||||
|
|
||||||
|
- Base: `delay = min(cap, base * 2^(attempt-1))`
|
||||||
|
- Cap: 30s (preserves current steady-state behavior)
|
||||||
|
- Jitter: ±20% of computed delay (prevents thundering herd)
|
||||||
|
- Reset attempt counter on a successful `connected` event from the server.
|
||||||
|
|
||||||
|
### R4 — Catch-up fetch on reconnect (optional code change)
|
||||||
|
|
||||||
|
On a successful reconnect, trigger `PollerService.shared.fetchNow()` once to recover any
|
||||||
|
events missed during the downtime window, rather than waiting for the next poll cycle.
|
||||||
|
This bounds event freshness loss to one fetch rather than the full poll interval.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Key Code Locations
|
||||||
|
|
||||||
|
All paths in `/Users/tigeren/Dev/xorbitlab/bugger/`:
|
||||||
|
|
||||||
|
| Location | What |
|
||||||
|
|---|---|
|
||||||
|
| `Sources/Services/BitableEventService.swift:25` | `reconnectDelay = 30` (fixed, no backoff) |
|
||||||
|
| `Sources/Services/BitableEventService.swift:21-23` | `isEnabled` guard |
|
||||||
|
| `Sources/Services/BitableEventService.swift:61-66` | Client idle-timeout config (300s request, infinite resource) |
|
||||||
|
| `Sources/Services/BitableEventService.swift:83` | Request timeout (300s) |
|
||||||
|
| `Sources/Services/BitableEventService.swift:92-100` | `closeStream` — cancels pending reconnect |
|
||||||
|
| `Sources/Services/BitableEventService.swift:102-124` | `handleStreamEnd` — auto-reconnect scheduling |
|
||||||
|
| `Sources/Services/BitableEventService.swift:126-138` | `handleEvent` — `change`→`fetchNow`, heartbeat no-op |
|
||||||
|
| `Sources/Services/BitableEventService.swift:170-226` | `SSESessionDelegate` — frame parser, comment/heartbeat ignored |
|
||||||
|
| `Sources/AppDelegate.swift:33` | `connect()` on app launch |
|
||||||
|
| `Sources/Views/Settings/SettingsView.swift:293` | `reconnect()` on settings save |
|
||||||
|
| `Sources/Views/Settings/SettingsView.swift:181` | `disconnect()` on field clear |
|
||||||
|
| `docs/BITABLE_CHANGE_NOTIFICATION_IMPL.md:686-693` | Server heartbeat spec (30s, must verify deployed) |
|
||||||
|
| `docs/BITABLE_CHANGE_NOTIFICATION_IMPL.md:707-711` | Server SSE response headers (keep-alive, no buffering) |
|
||||||
|
| `docs/TECH_INVESTIGATION.md:56-58,424,544` | Backoff references (rejected WSClient design only) |
|
||||||
Loading…
Reference in New Issue