From a1108298545fc7b2c8d610339b2b87d084ade62e Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 06:01:28 +0300 Subject: [PATCH 01/28] fix: route BrainBar search through Python hybrid helper --- .../Sources/BrainBar/BrainBarServer.swift | 23 +- .../BrainBar/HybridSearchHelperClient.swift | 265 ++++++++++++++++++ brain-bar/Sources/BrainBar/MCPRouter.swift | 77 ++++- .../BrainBarReliabilityTests.swift | 1 + .../BrainBarStartupRecoveryTests.swift | 1 + .../Tests/BrainBarTests/MCPRouterTests.swift | 119 ++++++++ .../SocketIntegrationTests.swift | 80 +++++- src/brainlayer/brainbar_hybrid_helper.py | 180 ++++++++++++ tests/test_brainbar_hybrid_helper.py | 55 ++++ 9 files changed, 782 insertions(+), 19 deletions(-) create mode 100644 brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift create mode 100644 src/brainlayer/brainbar_hybrid_helper.py create mode 100644 tests/test_brainbar_hybrid_helper.py diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift index 02366bdc..cc0a77e4 100644 --- a/brain-bar/Sources/BrainBar/BrainBarServer.swift +++ b/brain-bar/Sources/BrainBar/BrainBarServer.swift @@ -98,6 +98,9 @@ final class BrainBarServer: @unchecked Sendable { private var clients: [Int32: ClientState] = [:] private var router: MCPRouter! private var database: BrainDatabase! + private var hybridSearchHelperClient: HybridSearchHelperClient? + private let providedHybridSearchClient: HybridSearchClientProtocol? + private let enableHybridSearchHelper: Bool private var databaseRetryWorkItem: DispatchWorkItem? private var lastDatabaseRetryDelayMillis: UInt64? private var databaseOpenInProgress = false @@ -141,12 +144,16 @@ final class BrainBarServer: @unchecked Sendable { socketPath: String? = nil, dbPath: String? = nil, database: BrainDatabase? = nil, + hybridSearchClient: HybridSearchClientProtocol? = nil, + enableHybridSearchHelper: Bool = true, databaseRecoveryPolicy: DatabaseRecoveryPolicy = DatabaseRecoveryPolicy(), instanceLockPath: String? = nil ) { self.socketPath = socketPath ?? Self.defaultSocketPath() self.dbPath = dbPath ?? Self.defaultDBPath() providedDatabase = database + providedHybridSearchClient = hybridSearchClient + self.enableHybridSearchHelper = enableHybridSearchHelper self.databaseRecoveryPolicy = databaseRecoveryPolicy self.instanceLockPath = instanceLockPath ?? Self.defaultInstanceLockPath(socketPath: self.socketPath) } @@ -197,9 +204,21 @@ final class BrainBarServer: @unchecked Sendable { return } + let hybridClient: HybridSearchClientProtocol? + if let providedHybridSearchClient { + hybridClient = providedHybridSearchClient + } else if providedDatabase == nil && enableHybridSearchHelper { + let client = HybridSearchHelperClient(dbPath: dbPath) + client.start() + hybridSearchHelperClient = client + hybridClient = client + } else { + hybridClient = nil + } + // 1. Create router FIRST (no DB dependency). // initialize + tools/list work without a database. - router = MCPRouter() + router = MCPRouter(hybridSearchClient: hybridClient) // 2. Bind socket BEFORE database init. // After a restart the socket must exist before Claude Code tries @@ -542,6 +561,8 @@ final class BrainBarServer: @unchecked Sendable { database?.close() } database = nil + hybridSearchHelperClient?.stop() + hybridSearchHelperClient = nil databaseOpenInProgress = false instanceLock?.release() instanceLock = nil diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift new file mode 100644 index 00000000..c19395b9 --- /dev/null +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -0,0 +1,265 @@ +import Foundation + +struct HybridSearchResponse { + let text: String + let metadata: [String: Any] + + init(text: String, metadata: [String: Any] = [:]) { + self.text = text + self.metadata = metadata + } +} + +protocol HybridSearchClientProtocol: AnyObject, Sendable { + func search(arguments: [String: Any]) throws -> HybridSearchResponse +} + +final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sendable { + private let socketPath: String + private let dbPath: String + private let pythonExecutable: String + private let environment: [String: String] + private let queue = DispatchQueue(label: "com.brainlayer.brainbar.hybrid-helper") + private var process: Process? + + init( + socketPath: String? = nil, + dbPath: String, + pythonExecutable: String? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment + ) { + self.socketPath = socketPath ?? Self.defaultSocketPath() + self.dbPath = dbPath + self.pythonExecutable = pythonExecutable ?? Self.resolvePythonExecutable(environment: environment) + self.environment = environment + } + + static func defaultSocketPath() -> String { + "/tmp/brainbar-hybrid-\(ProcessInfo.processInfo.processIdentifier).sock" + } + + static func resolvePythonExecutable(environment: [String: String]) -> String { + if let explicit = environment["BRAINBAR_PYTHON"], !explicit.isEmpty { + return explicit + } + if let virtualEnv = environment["VIRTUAL_ENV"], !virtualEnv.isEmpty { + let candidate = "\(virtualEnv)/bin/python" + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + let repoCandidate = "/Users/etanheyman/Gits/brainlayer/.venv/bin/python" + if FileManager.default.isExecutableFile(atPath: repoCandidate) { + return repoCandidate + } + return "/usr/bin/env" + } + + func start() { + queue.sync { + startLocked() + } + } + + func stop() { + queue.sync { + process?.terminate() + process = nil + unlink(socketPath) + } + } + + func search(arguments: [String: Any]) throws -> HybridSearchResponse { + try queue.sync { + startLocked() + return try send(arguments: arguments) + } + } + + private func startLocked() { + if let process, process.isRunning { + return + } + + unlink(socketPath) + + let proc = Process() + if pythonExecutable == "/usr/bin/env" { + proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") + proc.arguments = [ + "python3", + "-m", + "brainlayer.brainbar_hybrid_helper", + "--socket-path", + socketPath, + "--db-path", + dbPath + ] + } else { + proc.executableURL = URL(fileURLWithPath: pythonExecutable) + proc.arguments = [ + "-m", + "brainlayer.brainbar_hybrid_helper", + "--socket-path", + socketPath, + "--db-path", + dbPath + ] + } + + var env = environment + env["BRAINLAYER_DB"] = dbPath + if env["PYTHONPATH"] == nil || env["PYTHONPATH"]?.isEmpty == true { + env["PYTHONPATH"] = "/Users/etanheyman/Gits/brainlayer/src" + } + proc.environment = env + proc.standardInput = Pipe() + proc.standardOutput = Pipe() + proc.standardError = FileHandle.standardError + + do { + try proc.run() + process = proc + NSLog("[BrainBar] Hybrid search helper started pid=%d socket=%@", proc.processIdentifier, socketPath) + } catch { + NSLog("[BrainBar] Failed to start hybrid search helper: %@", String(describing: error)) + process = nil + } + } + + private func send(arguments: [String: Any]) throws -> HybridSearchResponse { + let fd = try connectWithRetry() + defer { close(fd) } + + let payload: [String: Any] = [ + "method": "brain_search", + "arguments": arguments + ] + let data = try JSONSerialization.data(withJSONObject: payload) + var framed = data + framed.append(0x0A) + try writeAll(fd: fd, data: framed) + let responseData = try readLine(fd: fd) + let decoded = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] + guard let decoded else { + throw HybridSearchHelperError.invalidResponse + } + if let ok = decoded["ok"] as? Bool, !ok { + let message = decoded["error"] as? String ?? "unknown helper error" + throw HybridSearchHelperError.helperError(message) + } + guard let text = decoded["text"] as? String else { + throw HybridSearchHelperError.invalidResponse + } + let metadata = decoded["metadata"] as? [String: Any] ?? [:] + return HybridSearchResponse(text: text, metadata: metadata) + } + + private func connectWithRetry() throws -> Int32 { + var lastErrno: Int32 = 0 + for attempt in 0..<50 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw HybridSearchHelperError.socket(errno) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = socketPath.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + close(fd) + throw HybridSearchHelperError.socketPathTooLong(socketPath) + } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in + pathBytes.withUnsafeBufferPointer { src in + _ = memcpy(dest, src.baseAddress!, src.count) + } + } + } + + let rc = withUnsafePointer(to: &addr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { ptr in + connect(fd, ptr, socklen_t(MemoryLayout.size)) + } + } + if rc == 0 { + return fd + } + lastErrno = errno + close(fd) + usleep(useconds_t(min(50_000 + attempt * 10_000, 250_000))) + } + throw HybridSearchHelperError.connect(lastErrno) + } + + private func writeAll(fd: Int32, data: Data) throws { + try data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.baseAddress else { return } + var written = 0 + while written < data.count { + let count = write(fd, base.advanced(by: written), data.count - written) + if count < 0 { + if errno == EINTR { continue } + throw HybridSearchHelperError.write(errno) + } + if count == 0 { + throw HybridSearchHelperError.write(EPIPE) + } + written += count + } + } + } + + private func readLine(fd: Int32) throws -> Data { + var result = Data() + var byte = UInt8(0) + while true { + let count = read(fd, &byte, 1) + if count < 0 { + if errno == EINTR { continue } + throw HybridSearchHelperError.read(errno) + } + if count == 0 { + break + } + if byte == 0x0A { + break + } + result.append(byte) + } + guard !result.isEmpty else { + throw HybridSearchHelperError.invalidResponse + } + return result + } +} + +enum HybridSearchHelperError: LocalizedError { + case socket(Int32) + case socketPathTooLong(String) + case connect(Int32) + case write(Int32) + case read(Int32) + case invalidResponse + case helperError(String) + + var errorDescription: String? { + switch self { + case .socket(let code): + return "hybrid helper socket failed: errno \(code)" + case .socketPathTooLong(let path): + return "hybrid helper socket path too long: \(path)" + case .connect(let code): + return "hybrid helper connect failed: errno \(code)" + case .write(let code): + return "hybrid helper write failed: errno \(code)" + case .read(let code): + return "hybrid helper read failed: errno \(code)" + case .invalidResponse: + return "hybrid helper returned an invalid response" + case .helperError(let message): + return "hybrid helper error: \(message)" + } + } +} diff --git a/brain-bar/Sources/BrainBar/MCPRouter.swift b/brain-bar/Sources/BrainBar/MCPRouter.swift index 208b3a99..15081402 100644 --- a/brain-bar/Sources/BrainBar/MCPRouter.swift +++ b/brain-bar/Sources/BrainBar/MCPRouter.swift @@ -21,6 +21,7 @@ final class MCPRouter: @unchecked Sendable { } private var database: BrainDatabase? + private let hybridSearchClient: HybridSearchClientProtocol? let entityCache = EntityCache() private static let defaultStringMaxLength = 256 private static let defaultStringArrayMaxItems = 100 @@ -46,6 +47,10 @@ final class MCPRouter: @unchecked Sendable { "tags": (maxItems: 100, itemMaxLength: 128) ] + init(hybridSearchClient: HybridSearchClientProtocol? = nil) { + self.hybridSearchClient = hybridSearchClient + } + /// Inject database for tool handlers + load entity cache. func setDatabase(_ db: BrainDatabase) { self.database = db @@ -256,24 +261,68 @@ final class MCPRouter: @unchecked Sendable { } } - let results = try db.search( - query: query, - limit: limit, - project: project, - source: source, - tag: tag, - importanceMin: importanceMin, - subscriberID: subscriberID, - unreadOnly: unreadOnly - ) - let typedResults = results.map(SearchResult.init(payload:)) - let textSection = TextFormatter.formatSearchResults(query: query, results: typedResults, total: typedResults.count) + let textSection: String + let metadata: [String: Any] + if let hybridSearchClient, subscriberID == nil, !unreadOnly { + let response = try hybridSearchClient.search(arguments: hybridSearchArguments( + query: query, + limit: limit, + project: project, + source: source, + tag: tag, + importanceMin: importanceMin, + detail: args["detail"] as? String + )) + textSection = response.text + metadata = response.metadata + } else { + let results = try db.search( + query: query, + limit: limit, + project: project, + source: source, + tag: tag, + importanceMin: importanceMin, + subscriberID: subscriberID, + unreadOnly: unreadOnly + ) + let typedResults = results.map(SearchResult.init(payload:)) + textSection = TextFormatter.formatSearchResults(query: query, results: typedResults, total: typedResults.count) + metadata = [:] + } // KG section goes before the envelope if kgSection.isEmpty { - return ToolOutput(text: textSection) + return ToolOutput(text: textSection, metadata: metadata) + } + return ToolOutput(text: kgSection + "\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + textSection, metadata: metadata) + } + + private func hybridSearchArguments( + query: String, + limit: Int, + project: String?, + source: String?, + tag: String?, + importanceMin: Double?, + detail: String? + ) -> [String: Any] { + var arguments: [String: Any] = [ + "query": query, + "num_results": limit, + "source": source ?? "all", + "detail": detail ?? "compact" + ] + if let project { + arguments["project"] = project + } + if let tag { + arguments["tag"] = tag + } + if let importanceMin { + arguments["importance_min"] = importanceMin } - return ToolOutput(text: kgSection + "\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + textSection) + return arguments } private func handleBrainStore(_ args: [String: Any]) throws -> ToolOutput { diff --git a/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift b/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift index a0f276ac..3db57d7b 100644 --- a/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift +++ b/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift @@ -56,6 +56,7 @@ final class BrainBarReliabilityTests: XCTestCase { let server = BrainBarServer( socketPath: socketPath, dbPath: dbPath, + enableHybridSearchHelper: false, databaseRecoveryPolicy: .init( busyTimeoutMillis: 1_000, initialRetryDelayMillis: 25, diff --git a/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift b/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift index 8a4fbff6..a649ed5d 100644 --- a/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift +++ b/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift @@ -44,6 +44,7 @@ final class BrainBarStartupRecoveryTests: XCTestCase { let server = BrainBarServer( socketPath: socketPath, dbPath: dbPath, + enableHybridSearchHelper: false, databaseRecoveryPolicy: .init( busyTimeoutMillis: 50, initialRetryDelayMillis: 25, diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index 6032764c..03911934 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -291,6 +291,111 @@ final class MCPRouterTests: XCTestCase { XCTAssertEqual(response["id"] as? Int, 4) } + func testBrainSearchDelegatesNormalQueriesToHybridHelper() throws { + let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-helper-\(UUID().uuidString).db" + defer { try? FileManager.default.removeItem(atPath: tempDB) } + let db = BrainDatabase(path: tempDB) + defer { db.close() } + try db.insertChunk( + id: "fts-only-decoy", + content: "techgym speakers workshop decoy from the old Swift FTS path", + sessionId: "s1", + project: "brainlayer", + contentType: "assistant_text", + importance: 1 + ) + + let helper = RecordingHybridSearchClient( + response: HybridSearchResponse( + text: #""" +┌─ brain_search: "techgym speakers workshop" ─ 1 result +├─ [1] manual-a0b8a score:0.97 imp: 8 2026-05-16 +│ brainlayer │ Michal speakers workshop manual chunk +└─ +"""#, + metadata: ["structuredContent": ["query": "techgym speakers workshop"]] + ) + ) + let router = MCPRouter(hybridSearchClient: helper) + router.setDatabase(db) + + let response = router.handle([ + "jsonrpc": "2.0", + "id": 17, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": [ + "query": "techgym speakers workshop", + "num_results": 3, + "project": "brainlayer", + "source": "all", + "tag": "speakers-workshop", + "importance_min": 8, + "detail": "compact" + ] as [String: Any] + ] as [String: Any] + ]) + + let result = try XCTUnwrap(response["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + let text = content.first?["text"] as? String ?? "" + + XCTAssertTrue(text.contains("manual-a0b8a")) + XCTAssertFalse(text.contains("fts-only-decoy"), "Normal MCP brain_search must use the Python hybrid helper, not Swift FTS.") + XCTAssertEqual(helper.requests.count, 1) + XCTAssertEqual(helper.requests.first?["query"] as? String, "techgym speakers workshop") + XCTAssertEqual(helper.requests.first?["num_results"] as? Int, 3) + XCTAssertEqual(helper.requests.first?["project"] as? String, "brainlayer") + XCTAssertEqual(helper.requests.first?["source"] as? String, "all") + XCTAssertEqual(helper.requests.first?["tag"] as? String, "speakers-workshop") + XCTAssertEqual(helper.requests.first?["importance_min"] as? Double, 8) + XCTAssertEqual(helper.requests.first?["detail"] as? String, "compact") + XCTAssertNotNil(result["structuredContent"]) + } + + func testBrainSearchUnreadOnlyStaysOnBrainBarQueuePathWhenHybridHelperExists() throws { + let tempDB = NSTemporaryDirectory() + "brainbar-unread-helper-\(UUID().uuidString).db" + defer { try? FileManager.default.removeItem(atPath: tempDB) } + let db = BrainDatabase(path: tempDB) + defer { db.close() } + + try db.insertChunk(id: "read-1", content: "Agent message already delivered", sessionId: "s1", project: "test", contentType: "assistant_text", importance: 5, tags: "[\"agent-message\"]") + try db.insertChunk(id: "unread-1", content: "Agent message still unread", sessionId: "s2", project: "test", contentType: "assistant_text", importance: 5, tags: "[\"agent-message\"]") + _ = try db.upsertSubscription(agentID: "agent-1", tags: ["agent-message"]) + guard let readSeq = try db.chunkRowID(forChunkID: "read-1") else { + XCTFail("expected read-1 rowid") + return + } + try db.acknowledge(agentID: "agent-1", seq: readSeq) + + let helper = RecordingHybridSearchClient( + response: HybridSearchResponse(text: "helper should not be called") + ) + let router = MCPRouter(hybridSearchClient: helper) + router.setDatabase(db) + let response = router.handle([ + "jsonrpc": "2.0", + "id": 18, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": [ + "query": "agent message", + "subscriber_id": "agent-1", + "unread_only": true + ] as [String: Any] + ] as [String: Any] + ]) + + let result = response["result"] as? [String: Any] + let content = result?["content"] as? [[String: Any]] + let text = content?.first?["text"] as? String ?? "" + + XCTAssertTrue(text.contains("unread-1"), "Unread queue searches must preserve BrainBar subscriber cursor behavior.") + XCTAssertEqual(helper.requests.count, 0) + } + func testBrainMaintenanceRebuildTrigramToolReturnsProgressMetadata() throws { let tempDB = NSTemporaryDirectory() + "brainbar-maintenance-\(UUID().uuidString).db" defer { @@ -1576,3 +1681,17 @@ private func openSQLiteConnection(path: String) throws -> OpaquePointer { } return db } + +private final class RecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { + private let response: HybridSearchResponse + private(set) var requests: [[String: Any]] = [] + + init(response: HybridSearchResponse) { + self.response = response + } + + func search(arguments: [String: Any]) throws -> HybridSearchResponse { + requests.append(arguments) + return response + } +} diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index 2a281752..8ac89666 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -9,11 +9,14 @@ import XCTest final class SocketIntegrationTests: XCTestCase { let testSocketPath = "/tmp/brainbar-test-\(ProcessInfo.processInfo.processIdentifier).sock" var server: BrainBarServer! + var db: BrainDatabase! + var tempDBPath: String! override func setUp() { super.setUp() - let tempDB = NSTemporaryDirectory() + "brainbar-integration-\(UUID().uuidString).db" - server = BrainBarServer(socketPath: testSocketPath, dbPath: tempDB) + tempDBPath = NSTemporaryDirectory() + "brainbar-integration-\(UUID().uuidString).db" + db = BrainDatabase(path: tempDBPath) + server = BrainBarServer(socketPath: testSocketPath, dbPath: tempDBPath, database: db) server.start() // Give server time to bind Thread.sleep(forTimeInterval: 0.2) @@ -21,6 +24,10 @@ final class SocketIntegrationTests: XCTestCase { override func tearDown() { server.stop() + db.close() + try? FileManager.default.removeItem(atPath: tempDBPath) + try? FileManager.default.removeItem(atPath: tempDBPath + "-wal") + try? FileManager.default.removeItem(atPath: tempDBPath + "-shm") super.tearDown() } @@ -168,6 +175,47 @@ final class SocketIntegrationTests: XCTestCase { XCTAssertNotNil(response["result"]) } + func testMCPBrainSearchOverSocketUsesInjectedHybridHelper() throws { + server.stop() + let helper = SocketRecordingHybridSearchClient( + response: HybridSearchResponse( + text: #""" +┌─ brain_search: "techgym speakers workshop" ─ 1 result +├─ [1] manual-a0b8a score:0.97 imp: 8 2026-05-16 +└─ +"""# + ) + ) + server = BrainBarServer(socketPath: testSocketPath, dbPath: tempDBPath, database: db, hybridSearchClient: helper) + server.start() + Thread.sleep(forTimeInterval: 0.2) + + _ = try sendMCPRequest([ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": ["protocolVersion": "2024-11-05", "capabilities": [:] as [String: Any], + "clientInfo": ["name": "test", "version": "1.0"]] + ]) + + let response = try sendMCPRequest([ + "jsonrpc": "2.0", + "id": 19, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": ["query": "techgym speakers workshop", "num_results": 3, "source": "all"] as [String: Any] + ] + ]) + + let result = try XCTUnwrap(response["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + let text = content.first?["text"] as? String ?? "" + + XCTAssertTrue(text.contains("manual-a0b8a")) + XCTAssertEqual(helper.requests.count, 1) + XCTAssertEqual(helper.requests.first?["query"] as? String, "techgym speakers workshop") + XCTAssertEqual(helper.requests.first?["source"] as? String, "all") + } + func testMCPBrainSubscribeOverSocketReturnsCursorState() throws { _ = try sendMCPRequest([ "jsonrpc": "2.0", "id": 1, "method": "initialize", @@ -371,7 +419,9 @@ final class SocketIntegrationTests: XCTestCase { unsetenv("BRAINBAR_PENDING_STORES_PATH") } } - server = BrainBarServer(socketPath: testSocketPath, dbPath: dbPath) + let flushDB = BrainDatabase(path: dbPath) + defer { flushDB.close() } + server = BrainBarServer(socketPath: testSocketPath, dbPath: dbPath, database: flushDB) server.start() Thread.sleep(forTimeInterval: 0.2) @@ -713,7 +763,15 @@ final class SocketIntegrationTests: XCTestCase { func testRejectsOverlongSocketPath() throws { // sockaddr_un.sun_path is 104 bytes on macOS. A path > 104 should not crash. let longPath = "/tmp/" + String(repeating: "x", count: 200) + ".sock" - let longServer = BrainBarServer(socketPath: longPath, dbPath: NSTemporaryDirectory() + "test-long.db") + let longDBPath = NSTemporaryDirectory() + "test-long-\(UUID().uuidString).db" + let longDB = BrainDatabase(path: longDBPath) + defer { + longDB.close() + try? FileManager.default.removeItem(atPath: longDBPath) + try? FileManager.default.removeItem(atPath: longDBPath + "-wal") + try? FileManager.default.removeItem(atPath: longDBPath + "-shm") + } + let longServer = BrainBarServer(socketPath: longPath, dbPath: longDBPath, database: longDB) longServer.start() Thread.sleep(forTimeInterval: 0.2) @@ -916,3 +974,17 @@ final class SocketIntegrationTests: XCTestCase { throw NSError(domain: "test", code: 5, userInfo: [NSLocalizedDescriptionKey: "Timeout reading line JSON"]) } } + +private final class SocketRecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { + private let response: HybridSearchResponse + private(set) var requests: [[String: Any]] = [] + + init(response: HybridSearchResponse) { + self.response = response + } + + func search(arguments: [String: Any]) throws -> HybridSearchResponse { + requests.append(arguments) + return response + } +} diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py new file mode 100644 index 00000000..04aea9e2 --- /dev/null +++ b/src/brainlayer/brainbar_hybrid_helper.py @@ -0,0 +1,180 @@ +"""Persistent BrainBar helper for canonical Python hybrid search. + +BrainBar owns the MCP socket and write queue. This helper owns the Python +retrieval stack so BrainBar search results use the same RRF hybrid path as the +Python MCP implementation without porting ranking logic to Swift. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import signal +import socket +import sys +from pathlib import Path +from typing import Any + + +def _json_safe(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, tuple): + return [_json_safe(item) for item in value] + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items()} + return str(value) + + +class HybridSearchHelper: + def __init__(self, socket_path: Path, db_path: Path): + self.socket_path = socket_path + self.db_path = db_path + self._stopped = False + + def warm(self) -> None: + os.environ["BRAINLAYER_DB"] = os.fspath(self.db_path) + from brainlayer.mcp._shared import _get_embedding_model, _get_vector_store + + _get_vector_store() + model = _get_embedding_model() + model.embed_query("brainbar hybrid helper warmup") + + def serve_forever(self) -> None: + if self.socket_path.exists(): + self.socket_path.unlink() + + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + server.bind(os.fspath(self.socket_path)) + os.chmod(self.socket_path, 0o600) + server.listen(16) + server.settimeout(0.25) + self.warm() + + while not self._stopped: + try: + conn, _ = server.accept() + except TimeoutError: + continue + except OSError: + if self._stopped: + break + raise + with conn: + self._handle_connection(conn) + finally: + server.close() + try: + self.socket_path.unlink() + except FileNotFoundError: + pass + + def stop(self, *_: object) -> None: + self._stopped = True + + def _handle_connection(self, conn: socket.socket) -> None: + try: + raw = self._read_line(conn) + request = json.loads(raw.decode("utf-8")) + response = self._handle_request(request) + except Exception as exc: + response = {"ok": False, "error": str(exc)} + + payload = json.dumps(_json_safe(response), separators=(",", ":")).encode("utf-8") + b"\n" + conn.sendall(payload) + + @staticmethod + def _read_line(conn: socket.socket) -> bytes: + chunks: list[bytes] = [] + while True: + chunk = conn.recv(65536) + if not chunk: + break + if b"\n" in chunk: + before, _, _ = chunk.partition(b"\n") + chunks.append(before) + break + chunks.append(chunk) + if sum(len(part) for part in chunks) > 1_000_000: + raise ValueError("request exceeds 1MB") + if not chunks: + raise ValueError("empty request") + return b"".join(chunks) + + def _handle_request(self, request: dict[str, Any]) -> dict[str, Any]: + if request.get("method") != "brain_search": + raise ValueError(f"unsupported method: {request.get('method')}") + arguments = request.get("arguments") or {} + if not isinstance(arguments, dict): + raise ValueError("arguments must be an object") + + text, structured = asyncio.run(self._search(arguments)) + metadata: dict[str, Any] = {} + if structured is not None: + metadata["structuredContent"] = structured + return {"ok": True, "text": text, "metadata": metadata} + + async def _search(self, arguments: dict[str, Any]) -> tuple[str, dict[str, Any] | None]: + from brainlayer.mcp.search_handler import _brain_search + + source = arguments.get("source") + if source is None or source == "": + source = "all" + + result = await _brain_search( + query=str(arguments.get("query") or ""), + project=arguments.get("project"), + source=source, + tag=arguments.get("tag"), + importance_min=arguments.get("importance_min"), + num_results=int(arguments.get("num_results") or 5), + detail=str(arguments.get("detail") or "compact"), + ) + + if isinstance(result, tuple): + content, structured = result + return self._content_text(content), structured if isinstance(structured, dict) else None + if hasattr(result, "content"): + return self._content_text(result.content), None + return self._content_text(result), None + + @staticmethod + def _content_text(content: Any) -> str: + if isinstance(content, list): + parts = [] + for item in content: + text = getattr(item, "text", None) + if text is None and isinstance(item, dict): + text = item.get("text") + if text is not None: + parts.append(str(text)) + return "\n".join(parts) + text = getattr(content, "text", None) + if text is not None: + return str(text) + return str(content) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="BrainBar persistent hybrid search helper") + parser.add_argument("--socket-path", required=True) + parser.add_argument("--db-path", required=True) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + helper = HybridSearchHelper(socket_path=Path(args.socket_path), db_path=Path(args.db_path)) + signal.signal(signal.SIGTERM, helper.stop) + signal.signal(signal.SIGINT, helper.stop) + helper.serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_brainbar_hybrid_helper.py b/tests/test_brainbar_hybrid_helper.py new file mode 100644 index 00000000..7aa9bde8 --- /dev/null +++ b/tests/test_brainbar_hybrid_helper.py @@ -0,0 +1,55 @@ +from pathlib import Path + +from mcp.types import TextContent + +from brainlayer.brainbar_hybrid_helper import HybridSearchHelper + + +def test_helper_routes_brain_search_to_python_mcp_with_source_all_default(monkeypatch, tmp_path): + calls = [] + + async def fake_brain_search(**kwargs): + calls.append(kwargs) + return ( + [TextContent(type="text", text="hybrid result manual-a0b8a")], + {"query": kwargs["query"], "results": [{"chunk_id": "manual-a0b8a"}]}, + ) + + monkeypatch.setattr("brainlayer.mcp.search_handler._brain_search", fake_brain_search) + + helper = HybridSearchHelper(socket_path=tmp_path / "helper.sock", db_path=Path("/tmp/test.db")) + response = helper._handle_request( + { + "method": "brain_search", + "arguments": { + "query": "techgym speakers workshop", + "num_results": 3, + "project": "brainlayer", + "tag": "speakers-workshop", + "importance_min": 8, + "detail": "compact", + }, + } + ) + + assert response == { + "ok": True, + "text": "hybrid result manual-a0b8a", + "metadata": { + "structuredContent": { + "query": "techgym speakers workshop", + "results": [{"chunk_id": "manual-a0b8a"}], + } + }, + } + assert calls == [ + { + "query": "techgym speakers workshop", + "project": "brainlayer", + "source": "all", + "tag": "speakers-workshop", + "importance_min": 8, + "num_results": 3, + "detail": "compact", + } + ] From e0323c770d9e64d6c12c6df90681b9da446158b1 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 09:03:17 +0300 Subject: [PATCH 02/28] fix: harden BrainBar hybrid helper startup --- .../Sources/BrainBar/BrainBarServer.swift | 11 +++- .../BrainBar/HybridSearchHelperClient.swift | 56 +++++++++++++++++-- brain-bar/Sources/BrainBar/MCPRouter.swift | 49 ++++++++++------ .../HybridSearchHelperClientTests.swift | 48 ++++++++++++++++ .../Tests/BrainBarTests/MCPRouterTests.swift | 51 ++++++++++++----- .../RecordingHybridSearchClient.swift | 33 +++++++++++ .../SocketIntegrationTests.swift | 16 +----- brain-bar/build-app.sh | 24 +++++++- src/brainlayer/brainbar_hybrid_helper.py | 23 ++++++-- tests/test_brainbar_hybrid_helper.py | 17 ++++++ 10 files changed, 269 insertions(+), 59 deletions(-) create mode 100644 brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift create mode 100644 brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift index cc0a77e4..296896da 100644 --- a/brain-bar/Sources/BrainBar/BrainBarServer.swift +++ b/brain-bar/Sources/BrainBar/BrainBarServer.swift @@ -204,15 +204,17 @@ final class BrainBarServer: @unchecked Sendable { return } + let ownedHybridClient: HybridSearchHelperClient? let hybridClient: HybridSearchClientProtocol? if let providedHybridSearchClient { + ownedHybridClient = nil hybridClient = providedHybridSearchClient } else if providedDatabase == nil && enableHybridSearchHelper { let client = HybridSearchHelperClient(dbPath: dbPath) - client.start() - hybridSearchHelperClient = client + ownedHybridClient = client hybridClient = client } else { + ownedHybridClient = nil hybridClient = nil } @@ -286,6 +288,11 @@ final class BrainBarServer: @unchecked Sendable { NSLog("[BrainBar] Server listening on %@", socketPath) debugLog("SERVER STARTED — listening on \(socketPath)") + if let ownedHybridClient { + hybridSearchHelperClient = ownedHybridClient + ownedHybridClient.start() + } + // 3. NOW open the database (may take time on cold start with 8 GB file). // Connections accepted above queue in the listen backlog. // initialize / tools/list already work; tools/call returns a diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index c19395b9..a63e176d 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -34,6 +34,10 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen self.environment = environment } + deinit { + stop() + } + static func defaultSocketPath() -> String { "/tmp/brainbar-hybrid-\(ProcessInfo.processInfo.processIdentifier).sock" } @@ -48,13 +52,54 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen return candidate } } - let repoCandidate = "/Users/etanheyman/Gits/brainlayer/.venv/bin/python" - if FileManager.default.isExecutableFile(atPath: repoCandidate) { - return repoCandidate + if let repoRoot = normalizedRepoRoot(environment: environment) { + let candidate = "\(repoRoot)/.venv/bin/python" + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + if let python3 = findExecutable(named: "python3", path: environment["PATH"]) { + return python3 + } + if let python = findExecutable(named: "python", path: environment["PATH"]) { + return python } return "/usr/bin/env" } + static func resolvePythonPath(environment: [String: String]) -> String? { + if let existing = environment["PYTHONPATH"], !existing.isEmpty { + return existing + } + guard let repoRoot = normalizedRepoRoot(environment: environment) else { + return nil + } + let sourcePath = "\(repoRoot)/src" + guard FileManager.default.fileExists(atPath: sourcePath) else { + return nil + } + return sourcePath + } + + private static func normalizedRepoRoot(environment: [String: String]) -> String? { + guard let raw = environment["BRAINLAYER_REPO_ROOT"], + !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return URL(fileURLWithPath: raw).standardizedFileURL.path + } + + private static func findExecutable(named name: String, path: String?) -> String? { + let searchPath = path?.split(separator: ":").map(String.init) ?? [] + for dir in searchPath where !dir.isEmpty { + let candidate = "\(dir)/\(name)" + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + func start() { queue.sync { startLocked() @@ -109,9 +154,10 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen var env = environment env["BRAINLAYER_DB"] = dbPath - if env["PYTHONPATH"] == nil || env["PYTHONPATH"]?.isEmpty == true { - env["PYTHONPATH"] = "/Users/etanheyman/Gits/brainlayer/src" + if let pythonPath = Self.resolvePythonPath(environment: env) { + env["PYTHONPATH"] = pythonPath } + env["PYTHONUNBUFFERED"] = "1" proc.environment = env proc.standardInput = Pipe() proc.standardOutput = Pipe() diff --git a/brain-bar/Sources/BrainBar/MCPRouter.swift b/brain-bar/Sources/BrainBar/MCPRouter.swift index 15081402..ba98adbd 100644 --- a/brain-bar/Sources/BrainBar/MCPRouter.swift +++ b/brain-bar/Sources/BrainBar/MCPRouter.swift @@ -261,21 +261,7 @@ final class MCPRouter: @unchecked Sendable { } } - let textSection: String - let metadata: [String: Any] - if let hybridSearchClient, subscriberID == nil, !unreadOnly { - let response = try hybridSearchClient.search(arguments: hybridSearchArguments( - query: query, - limit: limit, - project: project, - source: source, - tag: tag, - importanceMin: importanceMin, - detail: args["detail"] as? String - )) - textSection = response.text - metadata = response.metadata - } else { + func searchViaBrainBarDatabase() throws -> (text: String, metadata: [String: Any]) { let results = try db.search( query: query, limit: limit, @@ -287,8 +273,37 @@ final class MCPRouter: @unchecked Sendable { unreadOnly: unreadOnly ) let typedResults = results.map(SearchResult.init(payload:)) - textSection = TextFormatter.formatSearchResults(query: query, results: typedResults, total: typedResults.count) - metadata = [:] + return ( + TextFormatter.formatSearchResults(query: query, results: typedResults, total: typedResults.count), + [:] + ) + } + + let textSection: String + let metadata: [String: Any] + if let hybridSearchClient, subscriberID == nil, !unreadOnly { + do { + let response = try hybridSearchClient.search(arguments: hybridSearchArguments( + query: query, + limit: limit, + project: project, + source: source, + tag: tag, + importanceMin: importanceMin, + detail: args["detail"] as? String + )) + textSection = response.text + metadata = response.metadata + } catch { + NSLog("[BrainBar] Hybrid search helper failed, falling back to BrainBar database search: %@", String(describing: error)) + let fallback = try searchViaBrainBarDatabase() + textSection = fallback.text + metadata = fallback.metadata + } + } else { + let fallback = try searchViaBrainBarDatabase() + textSection = fallback.text + metadata = fallback.metadata } // KG section goes before the envelope diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift new file mode 100644 index 00000000..5ad9ee0a --- /dev/null +++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift @@ -0,0 +1,48 @@ +import Darwin +import XCTest +@testable import BrainBar + +final class HybridSearchHelperClientTests: XCTestCase { + func testResolvePythonExecutableUsesInstallTimeRepoRoot() throws { + let repoRoot = NSTemporaryDirectory() + "brainbar-helper-repo-\(UUID().uuidString)" + let pythonPath = "\(repoRoot)/.venv/bin/python" + try FileManager.default.createDirectory( + atPath: "\(repoRoot)/.venv/bin", + withIntermediateDirectories: true + ) + FileManager.default.createFile(atPath: pythonPath, contents: Data("#!/bin/sh\n".utf8)) + chmod(pythonPath, 0o755) + defer { try? FileManager.default.removeItem(atPath: repoRoot) } + + let resolved = HybridSearchHelperClient.resolvePythonExecutable(environment: [ + "BRAINLAYER_REPO_ROOT": repoRoot, + "PATH": "/nonexistent" + ]) + + XCTAssertEqual(resolved, pythonPath) + } + + func testResolvePythonPathUsesRepoSourceDirectoryWhenUnset() throws { + let repoRoot = NSTemporaryDirectory() + "brainbar-helper-src-\(UUID().uuidString)" + try FileManager.default.createDirectory( + atPath: "\(repoRoot)/src", + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(atPath: repoRoot) } + + let resolved = HybridSearchHelperClient.resolvePythonPath(environment: [ + "BRAINLAYER_REPO_ROOT": repoRoot + ]) + + XCTAssertEqual(resolved, "\(repoRoot)/src") + } + + func testResolvePythonPathPreservesExistingPythonPath() throws { + let resolved = HybridSearchHelperClient.resolvePythonPath(environment: [ + "PYTHONPATH": "/custom/pythonpath", + "BRAINLAYER_REPO_ROOT": "/ignored" + ]) + + XCTAssertEqual(resolved, "/custom/pythonpath") + } +} diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index 03911934..6cfd31d4 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -354,6 +354,43 @@ final class MCPRouterTests: XCTestCase { XCTAssertNotNil(result["structuredContent"]) } + func testBrainSearchFallsBackToBrainBarDatabaseWhenHybridHelperFails() throws { + let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-fallback-\(UUID().uuidString).db" + defer { try? FileManager.default.removeItem(atPath: tempDB) } + let db = BrainDatabase(path: tempDB) + defer { db.close() } + try db.insertChunk( + id: "fallback-fts-result", + content: "techgym speakers workshop fallback result from BrainBar database search", + sessionId: "s1", + project: "brainlayer", + contentType: "assistant_text", + importance: 5 + ) + + let helper = RecordingHybridSearchClient() + let router = MCPRouter(hybridSearchClient: helper) + router.setDatabase(db) + + let response = router.handle([ + "jsonrpc": "2.0", + "id": 177, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": ["query": "fallback", "num_results": 3] + ] as [String: Any] + ]) + + let result = try XCTUnwrap(response["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + let text = content.first?["text"] as? String ?? "" + + XCTAssertEqual(helper.requests.count, 1) + XCTAssertTrue(text.contains("fallback-fts"), text) + XCTAssertNil(result["structuredContent"]) + } + func testBrainSearchUnreadOnlyStaysOnBrainBarQueuePathWhenHybridHelperExists() throws { let tempDB = NSTemporaryDirectory() + "brainbar-unread-helper-\(UUID().uuidString).db" defer { try? FileManager.default.removeItem(atPath: tempDB) } @@ -1681,17 +1718,3 @@ private func openSQLiteConnection(path: String) throws -> OpaquePointer { } return db } - -private final class RecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { - private let response: HybridSearchResponse - private(set) var requests: [[String: Any]] = [] - - init(response: HybridSearchResponse) { - self.response = response - } - - func search(arguments: [String: Any]) throws -> HybridSearchResponse { - requests.append(arguments) - return response - } -} diff --git a/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift b/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift new file mode 100644 index 00000000..db2cf5aa --- /dev/null +++ b/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift @@ -0,0 +1,33 @@ +import Foundation +@testable import BrainBar + +enum RecordingHybridSearchClientError: LocalizedError { + case injectedFailure + + var errorDescription: String? { + switch self { + case .injectedFailure: + return "injected hybrid search failure" + } + } +} + +final class RecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { + private let result: Result + private(set) var requests: [[String: Any]] = [] + + init(response: HybridSearchResponse) { + result = .success(response) + } + + init(error: Error = RecordingHybridSearchClientError.injectedFailure) { + result = .failure(error) + } + + deinit {} + + func search(arguments: [String: Any]) throws -> HybridSearchResponse { + requests.append(arguments) + return try result.get() + } +} diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index 8ac89666..b7dafcd2 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -177,7 +177,7 @@ final class SocketIntegrationTests: XCTestCase { func testMCPBrainSearchOverSocketUsesInjectedHybridHelper() throws { server.stop() - let helper = SocketRecordingHybridSearchClient( + let helper = RecordingHybridSearchClient( response: HybridSearchResponse( text: #""" ┌─ brain_search: "techgym speakers workshop" ─ 1 result @@ -974,17 +974,3 @@ final class SocketIntegrationTests: XCTestCase { throw NSError(domain: "test", code: 5, userInfo: [NSLocalizedDescriptionKey: "Timeout reading line JSON"]) } } - -private final class SocketRecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { - private let response: HybridSearchResponse - private(set) var requests: [[String: Any]] = [] - - init(response: HybridSearchResponse) { - self.response = response - } - - func search(arguments: [String: Any]) throws -> HybridSearchResponse { - requests.append(arguments) - return response - } -} diff --git a/brain-bar/build-app.sh b/brain-bar/build-app.sh index cf5e0a9d..d1631332 100755 --- a/brain-bar/build-app.sh +++ b/brain-bar/build-app.sh @@ -142,6 +142,22 @@ plist_set_string() { fi } +configure_launchagent_environment() { + local plist_path="$1" + local repo_root="$2" + local python_path="$repo_root/.venv/bin/python" + + "$PLIST_BUDDY" -c "Delete :EnvironmentVariables" "$plist_path" >/dev/null 2>&1 || true + "$PLIST_BUDDY" -c "Add :EnvironmentVariables dict" "$plist_path" + "$PLIST_BUDDY" -c "Add :EnvironmentVariables:BRAINLAYER_REPO_ROOT string \"$repo_root\"" "$plist_path" + if [ -d "$repo_root/src" ]; then + "$PLIST_BUDDY" -c "Add :EnvironmentVariables:PYTHONPATH string \"$repo_root/src\"" "$plist_path" + fi + if [ -x "$python_path" ]; then + "$PLIST_BUDDY" -c "Add :EnvironmentVariables:BRAINBAR_PYTHON string \"$python_path\"" "$plist_path" + fi +} + stamp_info_plist() { local plist_path="$1" local commit_sha="$2" @@ -172,7 +188,8 @@ wait_for_brainbar_exit() { wait_for_socket() { local path="$1" - for _ in $(seq 1 100); do + local attempts="${BRAINBAR_SOCKET_WAIT_ATTEMPTS:-300}" + for _ in $(seq 1 "$attempts"); do if [ -S "$path" ]; then return 0 fi @@ -253,7 +270,10 @@ fi if [ "$DEV_BUNDLE_BUILD" -eq 0 ] && [ -f "$PLIST_SRC" ]; then echo "[build-app] Installing LaunchAgent to $PLIST_DST..." bootout_launchagent - sed "s|/Applications/BrainBar.app|$APP_DIR|g" "$PLIST_SRC" > "$PLIST_DST" + TMP_PLIST="$(mktemp)" + sed "s|/Applications/BrainBar.app|$APP_DIR|g" "$PLIST_SRC" > "$TMP_PLIST" + configure_launchagent_environment "$TMP_PLIST" "$CURRENT_REPO_ROOT" + mv "$TMP_PLIST" "$PLIST_DST" launchctl bootstrap "$LAUNCH_DOMAIN" "$PLIST_DST" launchctl kickstart -k "$LAUNCH_DOMAIN/$PLIST_LABEL" echo "[build-app] LaunchAgent installed — BrainBar will auto-restart after quit" diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py index 04aea9e2..a9a90d91 100644 --- a/src/brainlayer/brainbar_hybrid_helper.py +++ b/src/brainlayer/brainbar_hybrid_helper.py @@ -66,6 +66,7 @@ def serve_forever(self) -> None: break raise with conn: + conn.settimeout(0.25) self._handle_connection(conn) finally: server.close() @@ -91,17 +92,22 @@ def _handle_connection(self, conn: socket.socket) -> None: @staticmethod def _read_line(conn: socket.socket) -> bytes: chunks: list[bytes] = [] + total = 0 while True: chunk = conn.recv(65536) if not chunk: break if b"\n" in chunk: before, _, _ = chunk.partition(b"\n") + total += len(before) + if total > 1_000_000: + raise ValueError("request exceeds 1MB") chunks.append(before) break - chunks.append(chunk) - if sum(len(part) for part in chunks) > 1_000_000: + total += len(chunk) + if total > 1_000_000: raise ValueError("request exceeds 1MB") + chunks.append(chunk) if not chunks: raise ValueError("empty request") return b"".join(chunks) @@ -157,19 +163,28 @@ def _content_text(content: Any) -> str: text = getattr(content, "text", None) if text is not None: return str(text) + if isinstance(content, dict): + text = content.get("text") + if text is not None: + return str(text) return str(content) def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="BrainBar persistent hybrid search helper") parser.add_argument("--socket-path", required=True) - parser.add_argument("--db-path", required=True) + parser.add_argument("--db-path") return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) - helper = HybridSearchHelper(socket_path=Path(args.socket_path), db_path=Path(args.db_path)) + if args.db_path: + os.environ["BRAINLAYER_DB"] = args.db_path + + from brainlayer.paths import get_db_path + + helper = HybridSearchHelper(socket_path=Path(args.socket_path), db_path=get_db_path()) signal.signal(signal.SIGTERM, helper.stop) signal.signal(signal.SIGINT, helper.stop) helper.serve_forever() diff --git a/tests/test_brainbar_hybrid_helper.py b/tests/test_brainbar_hybrid_helper.py index 7aa9bde8..205da9ad 100644 --- a/tests/test_brainbar_hybrid_helper.py +++ b/tests/test_brainbar_hybrid_helper.py @@ -1,6 +1,7 @@ from pathlib import Path from mcp.types import TextContent +import pytest from brainlayer.brainbar_hybrid_helper import HybridSearchHelper @@ -53,3 +54,19 @@ async def fake_brain_search(**kwargs): "detail": "compact", } ] + + +def test_content_text_extracts_single_dict_text(): + assert HybridSearchHelper._content_text({"type": "text", "text": "dict text"}) == "dict text" + + +def test_read_line_rejects_oversized_chunk_before_newline(): + class FakeSocket: + def __init__(self): + self.chunks = [b"x" * 1_000_001 + b"\n"] + + def recv(self, _size): + return self.chunks.pop(0) if self.chunks else b"" + + with pytest.raises(ValueError, match="request exceeds 1MB"): + HybridSearchHelper._read_line(FakeSocket()) From c232dc2895e8656b30f4aacbcb961c3e3b7ec8de Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 10:06:26 +0300 Subject: [PATCH 03/28] fix: address BrainBar hybrid review followups --- .../BrainBar/HybridSearchHelperClient.swift | 9 +++++- .../HybridSearchHelperClientTests.swift | 2 ++ .../RecordingHybridSearchClient.swift | 13 +++++++-- brain-bar/build-app.sh | 2 ++ src/brainlayer/brainbar_hybrid_helper.py | 10 +++++-- src/brainlayer/mcp/search_handler.py | 3 +- src/brainlayer/pipeline/entity_extraction.py | 9 +++++- tests/conftest.py | 10 +++++++ tests/test_brainbar_hybrid_helper.py | 18 +++++++++--- tests/test_digest_pipeline_v2.py | 6 ++-- tests/test_search_exact_chunk_id.py | 29 +++++++++++++++++++ 11 files changed, 98 insertions(+), 13 deletions(-) diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index a63e176d..d92130a5 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -15,6 +15,7 @@ protocol HybridSearchClientProtocol: AnyObject, Sendable { } final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sendable { + private static let maxResponseBytes = 10 * 1024 * 1024 private let socketPath: String private let dbPath: String private let pythonExecutable: String @@ -160,7 +161,7 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen env["PYTHONUNBUFFERED"] = "1" proc.environment = env proc.standardInput = Pipe() - proc.standardOutput = Pipe() + proc.standardOutput = FileHandle.nullDevice proc.standardError = FileHandle.standardError do { @@ -273,6 +274,9 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen break } result.append(byte) + if result.count > Self.maxResponseBytes { + throw HybridSearchHelperError.responseTooLarge(Self.maxResponseBytes) + } } guard !result.isEmpty else { throw HybridSearchHelperError.invalidResponse @@ -287,6 +291,7 @@ enum HybridSearchHelperError: LocalizedError { case connect(Int32) case write(Int32) case read(Int32) + case responseTooLarge(Int) case invalidResponse case helperError(String) @@ -302,6 +307,8 @@ enum HybridSearchHelperError: LocalizedError { return "hybrid helper write failed: errno \(code)" case .read(let code): return "hybrid helper read failed: errno \(code)" + case .responseTooLarge(let limit): + return "hybrid helper response exceeded \(limit) bytes" case .invalidResponse: return "hybrid helper returned an invalid response" case .helperError(let message): diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift index 5ad9ee0a..34b6caae 100644 --- a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift +++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift @@ -3,6 +3,8 @@ import XCTest @testable import BrainBar final class HybridSearchHelperClientTests: XCTestCase { + deinit {} + func testResolvePythonExecutableUsesInstallTimeRepoRoot() throws { let repoRoot = NSTemporaryDirectory() + "brainbar-helper-repo-\(UUID().uuidString)" let pythonPath = "\(repoRoot)/.venv/bin/python" diff --git a/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift b/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift index db2cf5aa..bbb7d754 100644 --- a/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift +++ b/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift @@ -14,7 +14,14 @@ enum RecordingHybridSearchClientError: LocalizedError { final class RecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { private let result: Result - private(set) var requests: [[String: Any]] = [] + private let lock = NSLock() + private var recordedRequests: [[String: Any]] = [] + + var requests: [[String: Any]] { + lock.lock() + defer { lock.unlock() } + return recordedRequests + } init(response: HybridSearchResponse) { result = .success(response) @@ -27,7 +34,9 @@ final class RecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked deinit {} func search(arguments: [String: Any]) throws -> HybridSearchResponse { - requests.append(arguments) + lock.lock() + recordedRequests.append(arguments) + lock.unlock() return try result.get() } } diff --git a/brain-bar/build-app.sh b/brain-bar/build-app.sh index d1631332..7c17a007 100755 --- a/brain-bar/build-app.sh +++ b/brain-bar/build-app.sh @@ -271,9 +271,11 @@ if [ "$DEV_BUNDLE_BUILD" -eq 0 ] && [ -f "$PLIST_SRC" ]; then echo "[build-app] Installing LaunchAgent to $PLIST_DST..." bootout_launchagent TMP_PLIST="$(mktemp)" + trap 'rm -f "$TMP_PLIST"' EXIT sed "s|/Applications/BrainBar.app|$APP_DIR|g" "$PLIST_SRC" > "$TMP_PLIST" configure_launchagent_environment "$TMP_PLIST" "$CURRENT_REPO_ROOT" mv "$TMP_PLIST" "$PLIST_DST" + trap - EXIT launchctl bootstrap "$LAUNCH_DOMAIN" "$PLIST_DST" launchctl kickstart -k "$LAUNCH_DOMAIN/$PLIST_LABEL" echo "[build-app] LaunchAgent installed — BrainBar will auto-restart after quit" diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py index a9a90d91..172e3c72 100644 --- a/src/brainlayer/brainbar_hybrid_helper.py +++ b/src/brainlayer/brainbar_hybrid_helper.py @@ -67,7 +67,10 @@ def serve_forever(self) -> None: raise with conn: conn.settimeout(0.25) - self._handle_connection(conn) + try: + self._handle_connection(conn) + except OSError: + continue finally: server.close() try: @@ -87,7 +90,10 @@ def _handle_connection(self, conn: socket.socket) -> None: response = {"ok": False, "error": str(exc)} payload = json.dumps(_json_safe(response), separators=(",", ":")).encode("utf-8") + b"\n" - conn.sendall(payload) + try: + conn.sendall(payload) + except OSError: + return @staticmethod def _read_line(conn: socket.socket) -> bytes: diff --git a/src/brainlayer/mcp/search_handler.py b/src/brainlayer/mcp/search_handler.py index 24611bba..0bfe82e9 100644 --- a/src/brainlayer/mcp/search_handler.py +++ b/src/brainlayer/mcp/search_handler.py @@ -210,7 +210,8 @@ def _exact_chunk_lookup_result( or is_precompact_checkpoint_content(chunk.get("content")) ): return _empty_exact_chunk_lookup_result(query) - if any(value is not None for value in (source, intent, sentiment, source_filter, correction_category)): + effective_source = None if source == "all" else source + if any(value is not None for value in (effective_source, intent, sentiment, source_filter, correction_category)): return None if project is not None: chunk_project = _normalize_project_name(chunk.get("project")) or chunk.get("project") diff --git a/src/brainlayer/pipeline/entity_extraction.py b/src/brainlayer/pipeline/entity_extraction.py index 01b90442..f97ad3c2 100644 --- a/src/brainlayer/pipeline/entity_extraction.py +++ b/src/brainlayer/pipeline/entity_extraction.py @@ -11,12 +11,19 @@ import json import logging +import os import re from dataclasses import dataclass, field from typing import Any, Optional logger = logging.getLogger(__name__) +_FALSEY = {"0", "false", "no"} + + +def _llm_extraction_enabled() -> bool: + return os.environ.get("BRAINLAYER_LLM_ENTITY_EXTRACTION", "1").lower() not in _FALSEY + @dataclass class ExtractedEntity: @@ -312,7 +319,7 @@ def extract_entities_llm( Returns: Tuple of (entities, relations). """ - if not text.strip(): + if not text.strip() or (llm_caller is None and not _llm_extraction_enabled()): return [], [] if llm_caller is None: diff --git a/tests/conftest.py b/tests/conftest.py index 436941c4..7cc43c0d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,16 @@ def eval_project() -> str: return f"eval-{uuid.uuid4().hex[:8]}" +@pytest.fixture(autouse=True) +def disable_live_gemini_for_unit_tests(monkeypatch, request): + """Keep unit tests from making live Gemini calls through local shell env.""" + if request.node.get_closest_marker("live"): + return + + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_GENERATIVE_AI_API_KEY", raising=False) + + @pytest.fixture def test_user() -> str: """Username for path-based tests. diff --git a/tests/test_brainbar_hybrid_helper.py b/tests/test_brainbar_hybrid_helper.py index 205da9ad..80960284 100644 --- a/tests/test_brainbar_hybrid_helper.py +++ b/tests/test_brainbar_hybrid_helper.py @@ -1,7 +1,5 @@ -from pathlib import Path - -from mcp.types import TextContent import pytest +from mcp.types import TextContent from brainlayer.brainbar_hybrid_helper import HybridSearchHelper @@ -18,7 +16,7 @@ async def fake_brain_search(**kwargs): monkeypatch.setattr("brainlayer.mcp.search_handler._brain_search", fake_brain_search) - helper = HybridSearchHelper(socket_path=tmp_path / "helper.sock", db_path=Path("/tmp/test.db")) + helper = HybridSearchHelper(socket_path=tmp_path / "helper.sock", db_path=tmp_path / "test.db") response = helper._handle_request( { "method": "brain_search", @@ -70,3 +68,15 @@ def recv(self, _size): with pytest.raises(ValueError, match="request exceeds 1MB"): HybridSearchHelper._read_line(FakeSocket()) + + +def test_handle_connection_ignores_client_disconnect_on_response(monkeypatch, tmp_path): + helper = HybridSearchHelper(socket_path=tmp_path / "helper.sock", db_path=tmp_path / "test.db") + monkeypatch.setattr(helper, "_read_line", lambda _conn: b'{"method":"brain_search","arguments":{"query":"x"}}') + monkeypatch.setattr(helper, "_handle_request", lambda _request: {"ok": True, "text": "result", "metadata": {}}) + + class ClosedSocket: + def sendall(self, _payload): + raise BrokenPipeError("client disconnected") + + helper._handle_connection(ClosedSocket()) diff --git a/tests/test_digest_pipeline_v2.py b/tests/test_digest_pipeline_v2.py index 730492a9..a74b8e2b 100644 --- a/tests/test_digest_pipeline_v2.py +++ b/tests/test_digest_pipeline_v2.py @@ -719,10 +719,11 @@ def test_digest_then_digest_finds_duplicate(self, tmp_store): class TestPerformance: """Tests that the digest pipeline meets performance targets.""" - def test_digest_under_2_seconds(self, tmp_store): + def test_digest_under_2_seconds(self, tmp_store, monkeypatch): """Digest of 1000-word content completes in <2 seconds.""" from brainlayer.pipeline.digest import digest_content + monkeypatch.setenv("BRAINLAYER_LLM_ENTITY_EXTRACTION", "0") content = " ".join(["word"] * 1000) mock_embed = MagicMock(return_value=[0.05] * 1024) @@ -737,10 +738,11 @@ def test_digest_under_2_seconds(self, tmp_store): assert elapsed < 2.0, f"Digest took {elapsed:.2f}s, expected <2s" - def test_connect_under_2_seconds(self, tmp_store): + def test_connect_under_2_seconds(self, tmp_store, monkeypatch): """Connect mode for 1000-word content completes in <2 seconds.""" from brainlayer.pipeline.digest import digest_connect + monkeypatch.setenv("BRAINLAYER_LLM_ENTITY_EXTRACTION", "0") content = " ".join(["word"] * 1000) mock_embed = MagicMock(return_value=[0.05] * 1024) diff --git a/tests/test_search_exact_chunk_id.py b/tests/test_search_exact_chunk_id.py index 7c855acb..aff40a36 100644 --- a/tests/test_search_exact_chunk_id.py +++ b/tests/test_search_exact_chunk_id.py @@ -68,6 +68,35 @@ async def test_brain_search_exact_chunk_id_defaults_missing_project_to_unknown() assert structured["results"][0]["project"] == "unknown" +@pytest.mark.asyncio +async def test_brain_search_exact_chunk_id_treats_source_all_as_unfiltered(): + """BrainBar forwards source='all'; exact chunk-id lookup must still short-circuit.""" + chunk_id = "brainbar-sourceall01" + mock_store = MagicMock() + mock_store.get_chunk.return_value = { + "id": chunk_id, + "content": "Exact chunk content reachable when source all is unfiltered", + "source_file": "docs/repro.md", + "project": "brainlayer", + "content_type": "note", + "importance": 8, + "created_at": "2026-05-18T09:15:00Z", + "summary": "Source all exact lookup repro", + } + + with ( + patch("brainlayer.mcp.search_handler._get_vector_store", return_value=mock_store), + patch( + "brainlayer.mcp.search_handler._search", + new=AsyncMock(side_effect=AssertionError("source='all' exact chunk-id query should bypass hybrid search")), + ), + ): + _, structured = await _brain_search(query=chunk_id, source="all", detail="compact") + + assert structured["total"] == 1 + assert structured["results"][0]["chunk_id"] == chunk_id + + @pytest.mark.asyncio async def test_brain_search_exact_checkpoint_chunk_id_returns_empty_without_fallback(): """Default exact chunk-id lookup must not leak checkpoint chunks via fallback search.""" From f8e2b8eae6309d2c90399fd88eb765b269d6858b Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 10:37:47 +0300 Subject: [PATCH 04/28] fix: report hybrid helper launch failures --- .../BrainBar/HybridSearchHelperClient.swift | 14 +++++++++++--- .../HybridSearchHelperClientTests.swift | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index d92130a5..019fda56 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -103,7 +103,11 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen func start() { queue.sync { - startLocked() + do { + try startLocked() + } catch { + NSLog("[BrainBar] Hybrid search helper startup deferred after failure: %@", String(describing: error)) + } } } @@ -117,12 +121,12 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen func search(arguments: [String: Any]) throws -> HybridSearchResponse { try queue.sync { - startLocked() + try startLocked() return try send(arguments: arguments) } } - private func startLocked() { + private func startLocked() throws { if let process, process.isRunning { return } @@ -171,6 +175,7 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen } catch { NSLog("[BrainBar] Failed to start hybrid search helper: %@", String(describing: error)) process = nil + throw HybridSearchHelperError.launch(String(describing: error)) } } @@ -291,6 +296,7 @@ enum HybridSearchHelperError: LocalizedError { case connect(Int32) case write(Int32) case read(Int32) + case launch(String) case responseTooLarge(Int) case invalidResponse case helperError(String) @@ -307,6 +313,8 @@ enum HybridSearchHelperError: LocalizedError { return "hybrid helper write failed: errno \(code)" case .read(let code): return "hybrid helper read failed: errno \(code)" + case .launch(let message): + return "hybrid helper launch failed: \(message)" case .responseTooLarge(let limit): return "hybrid helper response exceeded \(limit) bytes" case .invalidResponse: diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift index 34b6caae..72206a5a 100644 --- a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift +++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift @@ -47,4 +47,22 @@ final class HybridSearchHelperClientTests: XCTestCase { XCTAssertEqual(resolved, "/custom/pythonpath") } + + func testSearchReportsLaunchFailureWithoutSocketRetry() throws { + let client = HybridSearchHelperClient( + socketPath: NSTemporaryDirectory() + "brainbar-missing-helper-\(UUID().uuidString).sock", + dbPath: "/tmp/brainlayer-test.db", + pythonExecutable: "/no/such/python", + environment: [:] + ) + + do { + _ = try client.search(arguments: ["query": "techgym speakers workshop"]) + XCTFail("Expected launch failure") + } catch let error as HybridSearchHelperError { + guard case .launch = error else { + return XCTFail("Expected launch error, got \(error)") + } + } + } } From aa5448d4d525cd377c60a7c33e20d6cba6c6f327 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 10:48:10 +0300 Subject: [PATCH 05/28] fix: keep source all unfiltered for entity routing --- src/brainlayer/mcp/search_handler.py | 5 ++-- tests/test_search_filter_params.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/brainlayer/mcp/search_handler.py b/src/brainlayer/mcp/search_handler.py index 0bfe82e9..6427ba90 100644 --- a/src/brainlayer/mcp/search_handler.py +++ b/src/brainlayer/mcp/search_handler.py @@ -618,6 +618,7 @@ async def _brain_search( return await _recall(topic=query, project=project, max_results=max_results, include_audit=include_audit) store = _get_vector_store() + effective_source = None if source == "all" else source exact_chunk_hit = _exact_chunk_lookup_result( query, store, @@ -628,7 +629,7 @@ async def _brain_search( importance_min=importance_min, date_from=date_from, date_to=date_to, - source=source, + source=effective_source, intent=intent, sentiment=sentiment, source_filter=source_filter, @@ -647,7 +648,7 @@ async def _brain_search( has_active_filters = any( [ content_type, - source, + effective_source, tag, intent, importance_min, diff --git a/tests/test_search_filter_params.py b/tests/test_search_filter_params.py index 463535d9..8c91079a 100644 --- a/tests/test_search_filter_params.py +++ b/tests/test_search_filter_params.py @@ -271,6 +271,50 @@ def test_include_checkpoints_does_not_skip_entity_routing(self): assert store.kg_hybrid_search.call_args[1]["include_checkpoints"] is True mock_search.assert_not_awaited() + def test_source_all_does_not_skip_entity_routing(self): + """source='all' means unfiltered global search, not an active source filter.""" + store = MagicMock(count=MagicMock(return_value=100)) + store.kg_hybrid_search.return_value = { + "chunks": { + "ids": [[]], + "documents": [[]], + "metadatas": [[]], + "distances": [[]], + } + } + fact_items = [{"source": "Etan", "relation": "works_on", "target": "BrainLayer"}] + with ( + patch("brainlayer.mcp.search_handler._get_vector_store", return_value=store), + patch("brainlayer.mcp.search_handler._expanded_fts_query", return_value=None), + patch( + "brainlayer.mcp.search_handler._get_embedding_model", + return_value=MagicMock(embed_query=MagicMock(return_value=[0.1] * 1024)), + ), + patch( + "brainlayer.mcp.search_handler._detect_entities", + return_value=[{"id": "e1", "name": "Etan", "entity_type": "person"}], + ) as mock_detect, + patch("brainlayer.mcp.search_handler._kg_facts_sql", return_value=fact_items), + patch( + "brainlayer.mcp.search_handler._search", + new_callable=AsyncMock, + return_value=MagicMock(), + ) as mock_search, + patch("brainlayer.mcp.search_handler._normalize_project_name", return_value=None), + ): + _content, structured = asyncio.run( + _brain_search( + query="Etan BrainLayer context", + source="all", + ) + ) + + mock_detect.assert_called_once_with("Etan BrainLayer context", store) + store.kg_hybrid_search.assert_called_once() + mock_search.assert_not_awaited() + assert structured["entity"] == "Etan" + assert structured["facts"] == fact_items + # ── Input schema validation ────────────────────────────────────────────────── From fee3ae3bcf602f264e0e0d863b2b4851c1999111 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 10:56:53 +0300 Subject: [PATCH 06/28] fix: bound hybrid helper socket waits --- .../BrainBar/HybridSearchHelperClient.swift | 56 +++++++++++++++++-- .../HybridSearchHelperClientTests.swift | 20 +++++++ src/brainlayer/brainbar_hybrid_helper.py | 7 ++- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index 019fda56..311cf503 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -16,10 +16,12 @@ protocol HybridSearchClientProtocol: AnyObject, Sendable { final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sendable { private static let maxResponseBytes = 10 * 1024 * 1024 + private static let defaultSocketIOTimeout: TimeInterval = 60 private let socketPath: String private let dbPath: String private let pythonExecutable: String private let environment: [String: String] + private let socketIOTimeout: TimeInterval private let queue = DispatchQueue(label: "com.brainlayer.brainbar.hybrid-helper") private var process: Process? @@ -27,12 +29,14 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen socketPath: String? = nil, dbPath: String, pythonExecutable: String? = nil, - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + socketIOTimeout: TimeInterval = defaultSocketIOTimeout ) { self.socketPath = socketPath ?? Self.defaultSocketPath() self.dbPath = dbPath self.pythonExecutable = pythonExecutable ?? Self.resolvePythonExecutable(environment: environment) self.environment = environment + self.socketIOTimeout = socketIOTimeout } deinit { @@ -113,19 +117,30 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen func stop() { queue.sync { - process?.terminate() - process = nil - unlink(socketPath) + stopLocked() } } func search(arguments: [String: Any]) throws -> HybridSearchResponse { try queue.sync { try startLocked() - return try send(arguments: arguments) + do { + return try send(arguments: arguments) + } catch { + if Self.shouldRestartHelper(after: error) { + stopLocked() + } + throw error + } } } + private func stopLocked() { + process?.terminate() + process = nil + unlink(socketPath) + } + private func startLocked() throws { if let process, process.isRunning { return @@ -236,6 +251,7 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen } } if rc == 0 { + try Self.configureSocketTimeouts(fd: fd, timeout: socketIOTimeout) return fd } lastErrno = errno @@ -245,6 +261,33 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen throw HybridSearchHelperError.connect(lastErrno) } + static func configureSocketTimeouts(fd: Int32, timeout: TimeInterval) throws { + let boundedTimeout = max(timeout, 0.001) + let seconds = Int(boundedTimeout) + let microseconds = Int((boundedTimeout - Double(seconds)) * 1_000_000) + var timeval = timeval(tv_sec: seconds, tv_usec: Int32(microseconds)) + let size = socklen_t(MemoryLayout.size) + + if setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeval, size) != 0 { + throw HybridSearchHelperError.configureSocket(errno) + } + if setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeval, size) != 0 { + throw HybridSearchHelperError.configureSocket(errno) + } + } + + private static func shouldRestartHelper(after error: Error) -> Bool { + guard let helperError = error as? HybridSearchHelperError else { + return false + } + switch helperError { + case .connect, .configureSocket, .write, .read, .responseTooLarge, .invalidResponse: + return true + case .socket, .socketPathTooLong, .launch, .helperError: + return false + } + } + private func writeAll(fd: Int32, data: Data) throws { try data.withUnsafeBytes { rawBuffer in guard let base = rawBuffer.baseAddress else { return } @@ -294,6 +337,7 @@ enum HybridSearchHelperError: LocalizedError { case socket(Int32) case socketPathTooLong(String) case connect(Int32) + case configureSocket(Int32) case write(Int32) case read(Int32) case launch(String) @@ -309,6 +353,8 @@ enum HybridSearchHelperError: LocalizedError { return "hybrid helper socket path too long: \(path)" case .connect(let code): return "hybrid helper connect failed: errno \(code)" + case .configureSocket(let code): + return "hybrid helper socket timeout configuration failed: errno \(code)" case .write(let code): return "hybrid helper write failed: errno \(code)" case .read(let code): diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift index 72206a5a..ef95c4a3 100644 --- a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift +++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift @@ -65,4 +65,24 @@ final class HybridSearchHelperClientTests: XCTestCase { } } } + + func testConfigureSocketTimeoutsMakesReadsFinite() throws { + var fds: [Int32] = [0, 0] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) + defer { + close(fds[0]) + close(fds[1]) + } + + try HybridSearchHelperClient.configureSocketTimeouts(fd: fds[0], timeout: 0.05) + + var byte = UInt8(0) + let startedAt = Date() + let count = read(fds[0], &byte, 1) + let elapsed = Date().timeIntervalSince(startedAt) + + XCTAssertEqual(count, -1) + XCTAssertTrue(errno == EAGAIN || errno == EWOULDBLOCK) + XCTAssertLessThan(elapsed, 1.0) + } } diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py index 172e3c72..998af484 100644 --- a/src/brainlayer/brainbar_hybrid_helper.py +++ b/src/brainlayer/brainbar_hybrid_helper.py @@ -17,6 +17,9 @@ from pathlib import Path from typing import Any +_ACCEPT_TIMEOUT_SECONDS = 0.25 +_CONNECTION_TIMEOUT_SECONDS = 5.0 + def _json_safe(value: Any) -> Any: if value is None or isinstance(value, (str, int, float, bool)): @@ -53,7 +56,7 @@ def serve_forever(self) -> None: server.bind(os.fspath(self.socket_path)) os.chmod(self.socket_path, 0o600) server.listen(16) - server.settimeout(0.25) + server.settimeout(_ACCEPT_TIMEOUT_SECONDS) self.warm() while not self._stopped: @@ -66,7 +69,7 @@ def serve_forever(self) -> None: break raise with conn: - conn.settimeout(0.25) + conn.settimeout(_CONNECTION_TIMEOUT_SECONDS) try: self._handle_connection(conn) except OSError: From f3d4af513ccd16158b474a263fec91d6acc22c8c Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 11:11:06 +0300 Subject: [PATCH 07/28] fix: harden hybrid helper fallback cleanup --- .../BrainBar/HybridSearchHelperClient.swift | 9 +- brain-bar/Sources/BrainBar/MCPRouter.swift | 25 ++++-- .../Tests/BrainBarTests/MCPRouterTests.swift | 83 +++++++++++++++++++ src/brainlayer/brainbar_hybrid_helper.py | 1 - 4 files changed, 106 insertions(+), 12 deletions(-) diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index 311cf503..98c213b2 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -251,8 +251,13 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen } } if rc == 0 { - try Self.configureSocketTimeouts(fd: fd, timeout: socketIOTimeout) - return fd + do { + try Self.configureSocketTimeouts(fd: fd, timeout: socketIOTimeout) + return fd + } catch { + close(fd) + throw error + } } lastErrno = errno close(fd) diff --git a/brain-bar/Sources/BrainBar/MCPRouter.swift b/brain-bar/Sources/BrainBar/MCPRouter.swift index ba98adbd..51530685 100644 --- a/brain-bar/Sources/BrainBar/MCPRouter.swift +++ b/brain-bar/Sources/BrainBar/MCPRouter.swift @@ -248,17 +248,20 @@ final class MCPRouter: @unchecked Sendable { throw ToolError.noDatabase } - // Entity detection → KG fact lookup - var kgSection = "" - let hasActiveFilters = project != nil || sourceCountsAsFilter || tag != nil || subscriberID != nil || importanceMin != nil - if !hasActiveFilters { + func localKGSection() -> String { + let hasActiveFilters = project != nil || sourceCountsAsFilter || tag != nil || subscriberID != nil || importanceMin != nil + if hasActiveFilters { + return "" + } let detected = entityCache.detectEntities(in: query) - if let first = detected.first { - let facts = (try? db.lookupEntityFacts(entityName: first.name)) ?? [] - if !facts.isEmpty { - kgSection = TextFormatter.formatKGFacts(entity: first.name, facts: facts) - } + guard let first = detected.first else { + return "" + } + let facts = (try? db.lookupEntityFacts(entityName: first.name)) ?? [] + if facts.isEmpty { + return "" } + return TextFormatter.formatKGFacts(entity: first.name, facts: facts) } func searchViaBrainBarDatabase() throws -> (text: String, metadata: [String: Any]) { @@ -281,6 +284,7 @@ final class MCPRouter: @unchecked Sendable { let textSection: String let metadata: [String: Any] + let kgSection: String if let hybridSearchClient, subscriberID == nil, !unreadOnly { do { let response = try hybridSearchClient.search(arguments: hybridSearchArguments( @@ -294,16 +298,19 @@ final class MCPRouter: @unchecked Sendable { )) textSection = response.text metadata = response.metadata + kgSection = "" } catch { NSLog("[BrainBar] Hybrid search helper failed, falling back to BrainBar database search: %@", String(describing: error)) let fallback = try searchViaBrainBarDatabase() textSection = fallback.text metadata = fallback.metadata + kgSection = localKGSection() } } else { let fallback = try searchViaBrainBarDatabase() textSection = fallback.text metadata = fallback.metadata + kgSection = localKGSection() } // KG section goes before the envelope diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index 6cfd31d4..f721aef3 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -354,6 +354,48 @@ final class MCPRouterTests: XCTestCase { XCTAssertNotNil(result["structuredContent"]) } + func testBrainSearchHybridSuccessDoesNotPrependSwiftKGFacts() throws { + let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-no-duplicate-kg-\(UUID().uuidString).db" + defer { try? FileManager.default.removeItem(atPath: tempDB) } + let db = BrainDatabase(path: tempDB) + defer { db.close() } + try db.insertEntity(id: "entity-etan", type: "person", name: "Etan") + try db.insertEntity(id: "entity-brainlayer", type: "project", name: "BrainLayer") + try db.insertRelation(sourceId: "entity-etan", targetId: "entity-brainlayer", relationType: "works_on") + try db.insertChunk( + id: "swift-fts-result", + content: "Etan BrainLayer local fallback result", + sessionId: "s1", + project: "brainlayer", + contentType: "assistant_text", + importance: 5 + ) + + let helperText = "python helper canonical KG/search output" + let helper = RecordingHybridSearchClient(response: HybridSearchResponse(text: helperText)) + let router = MCPRouter(hybridSearchClient: helper) + router.setDatabase(db) + + let response = router.handle([ + "jsonrpc": "2.0", + "id": 171, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": ["query": "Etan BrainLayer", "num_results": 3] + ] as [String: Any] + ]) + + let result = try XCTUnwrap(response["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + let text = content.first?["text"] as? String ?? "" + + XCTAssertEqual(text, helperText) + XCTAssertEqual(helper.requests.count, 1) + XCTAssertFalse(text.contains("### ◆ Etan"), "Swift KG facts must not duplicate Python helper entity output.") + XCTAssertFalse(text.contains("WORKS_ON"), "Swift KG facts must not be prepended on hybrid helper success.") + } + func testBrainSearchFallsBackToBrainBarDatabaseWhenHybridHelperFails() throws { let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-fallback-\(UUID().uuidString).db" defer { try? FileManager.default.removeItem(atPath: tempDB) } @@ -391,6 +433,47 @@ final class MCPRouterTests: XCTestCase { XCTAssertNil(result["structuredContent"]) } + func testBrainSearchFallbackStillPrependsSwiftKGFacts() throws { + let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-fallback-kg-\(UUID().uuidString).db" + defer { try? FileManager.default.removeItem(atPath: tempDB) } + let db = BrainDatabase(path: tempDB) + defer { db.close() } + try db.insertEntity(id: "entity-etan", type: "person", name: "Etan") + try db.insertEntity(id: "entity-brainlayer", type: "project", name: "BrainLayer") + try db.insertRelation(sourceId: "entity-etan", targetId: "entity-brainlayer", relationType: "works_on") + try db.insertChunk( + id: "fallback-kg-result", + content: "Etan BrainLayer fallback result from BrainBar database search", + sessionId: "s1", + project: "brainlayer", + contentType: "assistant_text", + importance: 5 + ) + + let helper = RecordingHybridSearchClient() + let router = MCPRouter(hybridSearchClient: helper) + router.setDatabase(db) + + let response = router.handle([ + "jsonrpc": "2.0", + "id": 172, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": ["query": "Etan BrainLayer", "num_results": 3] + ] as [String: Any] + ]) + + let result = try XCTUnwrap(response["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + let text = content.first?["text"] as? String ?? "" + + XCTAssertEqual(helper.requests.count, 1) + XCTAssertTrue(text.contains("### ◆ Etan"), text) + XCTAssertTrue(text.contains("WORKS_ON"), text) + XCTAssertTrue(text.contains("fallback result from BrainBar database search"), text) + } + func testBrainSearchUnreadOnlyStaysOnBrainBarQueuePathWhenHybridHelperExists() throws { let tempDB = NSTemporaryDirectory() + "brainbar-unread-helper-\(UUID().uuidString).db" defer { try? FileManager.default.removeItem(atPath: tempDB) } diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py index 998af484..f3ee2646 100644 --- a/src/brainlayer/brainbar_hybrid_helper.py +++ b/src/brainlayer/brainbar_hybrid_helper.py @@ -194,7 +194,6 @@ def main(argv: list[str] | None = None) -> int: from brainlayer.paths import get_db_path helper = HybridSearchHelper(socket_path=Path(args.socket_path), db_path=get_db_path()) - signal.signal(signal.SIGTERM, helper.stop) signal.signal(signal.SIGINT, helper.stop) helper.serve_forever() return 0 From 942d1958b4374fcb74c3d50384d93e361db92d9d Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 12:04:28 +0300 Subject: [PATCH 08/28] fix: sanitize hybrid helper responses --- .../Sources/BrainBar/BrainBarServer.swift | 6 ++- .../BrainBar/HybridSearchHelperClient.swift | 54 +++++++++++++------ brain-bar/Sources/BrainBar/MCPRouter.swift | 10 +++- .../Tests/BrainBarTests/MCPRouterTests.swift | 8 ++- .../SocketIntegrationTests.swift | 5 +- 5 files changed, 64 insertions(+), 19 deletions(-) diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift index 296896da..7967fc0e 100644 --- a/brain-bar/Sources/BrainBar/BrainBarServer.swift +++ b/brain-bar/Sources/BrainBar/BrainBarServer.swift @@ -290,7 +290,11 @@ final class BrainBarServer: @unchecked Sendable { if let ownedHybridClient { hybridSearchHelperClient = ownedHybridClient - ownedHybridClient.start() + do { + try ownedHybridClient.start() + } catch { + NSLog("[BrainBar] Hybrid search helper startup deferred after failure: %@", String(describing: error)) + } } // 3. NOW open the database (may take time on cold start with 8 GB file). diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index 98c213b2..82a56141 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -17,12 +17,14 @@ protocol HybridSearchClientProtocol: AnyObject, Sendable { final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sendable { private static let maxResponseBytes = 10 * 1024 * 1024 private static let defaultSocketIOTimeout: TimeInterval = 60 + private static let queueKey = DispatchSpecificKey() private let socketPath: String private let dbPath: String private let pythonExecutable: String private let environment: [String: String] private let socketIOTimeout: TimeInterval private let queue = DispatchQueue(label: "com.brainlayer.brainbar.hybrid-helper") + private let queueID = UUID() private var process: Process? init( @@ -37,6 +39,7 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen self.pythonExecutable = pythonExecutable ?? Self.resolvePythonExecutable(environment: environment) self.environment = environment self.socketIOTimeout = socketIOTimeout + queue.setSpecific(key: Self.queueKey, value: queueID) } deinit { @@ -105,17 +108,26 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen return nil } - func start() { - queue.sync { - do { - try startLocked() - } catch { - NSLog("[BrainBar] Hybrid search helper startup deferred after failure: %@", String(describing: error)) - } + var isRunning: Bool { + if DispatchQueue.getSpecific(key: Self.queueKey) == queueID { + return process?.isRunning == true + } + return queue.sync { + process?.isRunning == true + } + } + + func start() throws { + try queue.sync { + try startLocked() } } func stop() { + if DispatchQueue.getSpecific(key: Self.queueKey) == queueID { + stopLocked() + return + } queue.sync { stopLocked() } @@ -136,7 +148,10 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen } private func stopLocked() { - process?.terminate() + if let process, process.isRunning { + process.terminate() + process.waitUntilExit() + } process = nil unlink(socketPath) } @@ -313,9 +328,12 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen private func readLine(fd: Int32) throws -> Data { var result = Data() - var byte = UInt8(0) + let bufferSize = 8192 + var buffer = [UInt8](repeating: 0, count: bufferSize) while true { - let count = read(fd, &byte, 1) + let count = buffer.withUnsafeMutableBytes { rawBuffer in + read(fd, rawBuffer.baseAddress, bufferSize) + } if count < 0 { if errno == EINTR { continue } throw HybridSearchHelperError.read(errno) @@ -323,12 +341,18 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen if count == 0 { break } - if byte == 0x0A { - break + + let chunk = buffer[.. 0 { + if result.count + endIndex > Self.maxResponseBytes { + throw HybridSearchHelperError.responseTooLarge(Self.maxResponseBytes) + } + result.append(contentsOf: buffer[.. Self.maxResponseBytes { - throw HybridSearchHelperError.responseTooLarge(Self.maxResponseBytes) + if newlineIndex != nil { + break } } guard !result.isEmpty else { diff --git a/brain-bar/Sources/BrainBar/MCPRouter.swift b/brain-bar/Sources/BrainBar/MCPRouter.swift index 51530685..bc7de868 100644 --- a/brain-bar/Sources/BrainBar/MCPRouter.swift +++ b/brain-bar/Sources/BrainBar/MCPRouter.swift @@ -297,7 +297,7 @@ final class MCPRouter: @unchecked Sendable { detail: args["detail"] as? String )) textSection = response.text - metadata = response.metadata + metadata = sanitizedHybridMetadata(response.metadata) kgSection = "" } catch { NSLog("[BrainBar] Hybrid search helper failed, falling back to BrainBar database search: %@", String(describing: error)) @@ -320,6 +320,14 @@ final class MCPRouter: @unchecked Sendable { return ToolOutput(text: kgSection + "\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + textSection, metadata: metadata) } + private func sanitizedHybridMetadata(_ metadata: [String: Any]) -> [String: Any] { + var allowed: [String: Any] = [:] + if let structuredContent = metadata["structuredContent"] { + allowed["structuredContent"] = structuredContent + } + return allowed + } + private func hybridSearchArguments( query: String, limit: Int, diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index f721aef3..92e52d6c 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -313,7 +313,11 @@ final class MCPRouterTests: XCTestCase { │ brainlayer │ Michal speakers workshop manual chunk └─ """#, - metadata: ["structuredContent": ["query": "techgym speakers workshop"]] + metadata: [ + "content": "helper must not overwrite MCP content", + "isError": true, + "structuredContent": ["query": "techgym speakers workshop"] + ] ) ) let router = MCPRouter(hybridSearchClient: helper) @@ -352,6 +356,8 @@ final class MCPRouterTests: XCTestCase { XCTAssertEqual(helper.requests.first?["importance_min"] as? Double, 8) XCTAssertEqual(helper.requests.first?["detail"] as? String, "compact") XCTAssertNotNil(result["structuredContent"]) + XCTAssertNil(result["isError"], "Hybrid helper metadata must not overwrite reserved MCP result keys.") + XCTAssertNotNil(result["content"] as? [[String: Any]], "Hybrid helper metadata must not overwrite MCP content.") } func testBrainSearchHybridSuccessDoesNotPrependSwiftKGFacts() throws { diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index b7dafcd2..91a2f126 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -420,8 +420,11 @@ final class SocketIntegrationTests: XCTestCase { } } let flushDB = BrainDatabase(path: dbPath) - defer { flushDB.close() } server = BrainBarServer(socketPath: testSocketPath, dbPath: dbPath, database: flushDB) + defer { + server.stop() + flushDB.close() + } server.start() Thread.sleep(forTimeInterval: 0.2) From b65df049fb7735aadb8cd8258014e4f306297c2a Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 12:10:18 +0300 Subject: [PATCH 09/28] feat: stream BrainBar brain bus events --- brain-bar/Sources/BrainBar/BrainBarApp.swift | 6 +- .../Sources/BrainBar/BrainBarServer.swift | 101 +++++++++- .../Sources/BrainBar/BrainBusClient.swift | 170 ++++++++++++++++ .../Sources/BrainBar/BrainBusEvent.swift | 190 ++++++++++++++++++ .../BrainBar/Dashboard/StatsCollector.swift | 39 ++-- .../BrainBarTests/BrainBusEventHubTests.swift | 78 +++++++ .../Tests/BrainBarTests/DashboardTests.swift | 34 ++++ .../SocketIntegrationTests.swift | 61 ++++++ 8 files changed, 663 insertions(+), 16 deletions(-) create mode 100644 brain-bar/Sources/BrainBar/BrainBusClient.swift create mode 100644 brain-bar/Sources/BrainBar/BrainBusEvent.swift create mode 100644 brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift diff --git a/brain-bar/Sources/BrainBar/BrainBarApp.swift b/brain-bar/Sources/BrainBar/BrainBarApp.swift index 4b17bea3..2a66001a 100644 --- a/brain-bar/Sources/BrainBar/BrainBarApp.swift +++ b/brain-bar/Sources/BrainBar/BrainBarApp.swift @@ -90,7 +90,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.runtime.install( collector: self.collector ?? StatsCollector( dbPath: dbPath, - daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier) + daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + brainBusEvents: BrainBusClient() ), injectionStore: self.injectionStore, database: database @@ -101,7 +102,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let collector = StatsCollector( dbPath: dbPath, - daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier) + daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + brainBusEvents: BrainBusClient() ) let injectionStore = try? InjectionStore(databasePath: dbPath) diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift index 296896da..5b57402b 100644 --- a/brain-bar/Sources/BrainBar/BrainBarServer.swift +++ b/brain-bar/Sources/BrainBar/BrainBarServer.swift @@ -101,6 +101,8 @@ final class BrainBarServer: @unchecked Sendable { private var hybridSearchHelperClient: HybridSearchHelperClient? private let providedHybridSearchClient: HybridSearchClientProtocol? private let enableHybridSearchHelper: Bool + private let brainBus = BrainBusEventHub() + private var brainBusQueueDepthEstimate = 0 private var databaseRetryWorkItem: DispatchWorkItem? private var lastDatabaseRetryDelayMillis: UInt64? private var databaseOpenInProgress = false @@ -108,8 +110,9 @@ final class BrainBarServer: @unchecked Sendable { var onDatabaseReady: (@Sendable (BrainDatabase) -> Void)? var onStartRejected: (@Sendable (String) -> Void)? /// Maximum EAGAIN retries before disconnecting a stalled client. - /// Each retry sleeps 1ms, so 10 retries = 10ms max blocking the serial queue. - static let maxWriteRetries = 10 + /// Each retry sleeps 1ms; 50 retries keeps false-positive disconnects + /// below the UI budget while still bounding serial-queue stalls. + static let maxWriteRetries = 50 private let debugLogPath = "/tmp/brainbar-debug.log" private func debugLog(_ msg: String) { @@ -138,6 +141,7 @@ final class BrainBarServer: @unchecked Sendable { var usesContentLengthFraming: Bool = true var agentID: String? var subscribedTags: Set = [] + var brainBusSubscriptionID: BrainBusEventHub.SubscriptionID? } init( @@ -338,6 +342,9 @@ final class BrainBarServer: @unchecked Sendable { database = db router.setDatabase(db) onDatabaseReady?(db) + brainBus.publish(.dbBusy(false)) + brainBus.publish(.queueDepth(brainBusQueueDepthEstimate)) + brainBus.publish(.enrichStatus("idle")) NSLog("[BrainBar] Database ready (%@)", dbPath) return } @@ -346,6 +353,7 @@ final class BrainBarServer: @unchecked Sendable { db.close() } NSLog("[BrainBar] ⚠️ DATABASE FAILED TO OPEN — tools/call will return errors (%@)", dbPath) + brainBus.publish(.dbBusy(true)) scheduleDatabaseRetry(lastError: db.lastOpenError) } @@ -417,6 +425,7 @@ final class BrainBarServer: @unchecked Sendable { if !messages.isEmpty { state.usesContentLengthFraming = state.framing.lastExtractUsedContentLength } + clients[fd] = state debugLog("EXTRACTED \(messages.count) messages from fd=\(fd) (framing=\(state.usesContentLengthFraming ? "content-length" : "newline-json"), buffer remaining: \(state.framing.bufferCount) bytes)") for msg in messages { let method = msg["method"] as? String ?? "" @@ -454,11 +463,16 @@ final class BrainBarServer: @unchecked Sendable { if toolCall.name == "brain_store", !isToolError(response) { publishStoredChunks(response: response, arguments: toolCall.arguments) } + if toolCall.name == "brain_enrich", !isToolError(response) { + brainBus.publish(.enrichStatus("running")) + } return response } } if let method = request["method"] as? String { switch method { + case "watch-brain-bus": + return handleWatchBrainBus(fd: fd) default: break } @@ -511,6 +525,74 @@ final class BrainBarServer: @unchecked Sendable { } } + private func writeFramedData(fd: Int32, data: Data) -> Bool { + data.withUnsafeBytes { ptr in + var totalWritten = 0 + var eagainRetries = 0 + while totalWritten < data.count { + let n = write(fd, ptr.baseAddress!.advanced(by: totalWritten), data.count - totalWritten) + if n < 0 { + if errno == EAGAIN || errno == EWOULDBLOCK { + eagainRetries += 1 + if eagainRetries > Self.maxWriteRetries { + return false + } + usleep(1000) + continue + } + return false + } + if n == 0 { + return false + } + totalWritten += n + eagainRetries = 0 + } + return true + } + } + + private func handleWatchBrainBus(fd: Int32) -> [String: Any] { + guard var client = clients[fd] else { return [:] } + if let existing = client.brainBusSubscriptionID { + brainBus.unsubscribe(existing) + } + + let useContentLength = client.usesContentLengthFraming + let subscription = brainBus.subscribe { [weak self] event in + guard let self else { return false } + guard let response = self.brainBusNotification(event: event) else { return true } + let framed: Data + do { + if useContentLength { + framed = try MCPFraming.encode(response) + } else { + var data = try MCPFraming.encodeJSONResponse(response) + data.append(0x0A) + framed = data + } + } catch { + return true + } + + let delivered = self.writeFramedData(fd: fd, data: framed) + if !delivered { + self.queue.async { [weak self] in + self?.disconnectClient(fd: fd) + } + } + return delivered + } + + client.brainBusSubscriptionID = subscription + clients[fd] = client + brainBus.publish(.dbBusy(database == nil)) + brainBus.publish(.queueDepth(brainBusQueueDepthEstimate)) + brainBus.publish(.enrichStatus("idle")) + brainBus.publish(.healthTick(openConnections: clients.count)) + return [:] + } + private static let claudeExtensionRawChunkLimit = 8192 private static func encodeRawJSONResponse(_ response: [String: Any]) -> Data? { @@ -545,6 +627,9 @@ final class BrainBarServer: @unchecked Sendable { } private func disconnectClient(fd: Int32) { + if let subscriptionID = clients[fd]?.brainBusSubscriptionID { + brainBus.unsubscribe(subscriptionID) + } if let agentID = clients[fd]?.agentID { try? database?.markSubscriberDisconnected(agentID: agentID) } @@ -761,6 +846,9 @@ final class BrainBarServer: @unchecked Sendable { } private func publishStoredChunk(stored: StoreResultPayload, content: String, tags: [String], importance: Int) { + brainBusQueueDepthEstimate += 1 + brainBus.publish(.queueDepth(brainBusQueueDepthEstimate)) + brainBus.publish(.lastChunkID(stored.chunkID)) guard !tags.isEmpty else { return } let tagSet = Set(tags) for (clientFD, client) in Array(clients) { @@ -858,4 +946,13 @@ final class BrainBarServer: @unchecked Sendable { } return object } + + private func brainBusNotification(event: BrainBusEvent) -> [String: Any]? { + guard let params = jsonObject(event) else { return nil } + return [ + "jsonrpc": "2.0", + "method": "notifications/brain-bus", + "params": params + ] + } } diff --git a/brain-bar/Sources/BrainBar/BrainBusClient.swift b/brain-bar/Sources/BrainBar/BrainBusClient.swift new file mode 100644 index 00000000..6896bebf --- /dev/null +++ b/brain-bar/Sources/BrainBar/BrainBusClient.swift @@ -0,0 +1,170 @@ +import Darwin +import Foundation + +protocol BrainBusEventSource: Sendable { + func events() -> AsyncStream +} + +final class BrainBusClient: BrainBusEventSource, @unchecked Sendable { + private let socketPath: String + private let reconnectDelay: TimeInterval + private let queue = DispatchQueue(label: "com.brainlayer.brainbar.brain-bus-client") + + init(socketPath: String = BrainBarServer.defaultSocketPath(), reconnectDelay: TimeInterval = 1.0) { + self.socketPath = socketPath + self.reconnectDelay = reconnectDelay + } + + func events() -> AsyncStream { + AsyncStream { continuation in + let run = BrainBusClientRun( + socketPath: socketPath, + reconnectDelay: reconnectDelay, + continuation: continuation + ) + continuation.onTermination = { @Sendable _ in + run.cancel() + } + queue.async { + run.start() + } + } + } +} + +private final class BrainBusClientRun: @unchecked Sendable { + private let socketPath: String + private let reconnectDelay: TimeInterval + private let continuation: AsyncStream.Continuation + private let lock = NSLock() + private var cancelled = false + private var currentFD: Int32 = -1 + + init( + socketPath: String, + reconnectDelay: TimeInterval, + continuation: AsyncStream.Continuation + ) { + self.socketPath = socketPath + self.reconnectDelay = reconnectDelay + self.continuation = continuation + } + + func start() { + while !isCancelled { + autoreleasepool { + if let fd = try? connect() { + setCurrentFD(fd) + sendWatchCommand(fd: fd) + readEvents(fd: fd) + close(fd) + setCurrentFD(-1) + } + } + if !isCancelled { + Thread.sleep(forTimeInterval: reconnectDelay) + } + } + continuation.finish() + } + + func cancel() { + let fd: Int32 = lock.withLock { + cancelled = true + return currentFD + } + if fd >= 0 { + shutdown(fd, SHUT_RDWR) + } + } + + private var isCancelled: Bool { + lock.withLock { cancelled } + } + + private func setCurrentFD(_ fd: Int32) { + lock.withLock { + currentFD = fd + } + } + + private func connect() throws -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw BrainBusClientError.socket(errno) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = socketPath.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + close(fd) + throw BrainBusClientError.socketPathTooLong(socketPath) + } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in + pathBytes.withUnsafeBufferPointer { src in + _ = memcpy(dest, src.baseAddress!, src.count) + } + } + } + + let result = withUnsafePointer(to: &addr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { ptr in + Darwin.connect(fd, ptr, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + let code = errno + close(fd) + throw BrainBusClientError.connect(code) + } + return fd + } + + private func sendWatchCommand(fd: Int32) { + let request: [String: Any] = [ + "jsonrpc": "2.0", + "id": 1, + "method": "watch-brain-bus", + ] + guard var data = try? JSONSerialization.data(withJSONObject: request) else { return } + data.append(0x0A) + data.withUnsafeBytes { ptr in + _ = write(fd, ptr.baseAddress!, data.count) + } + } + + private func readEvents(fd: Int32) { + var buffer = Data() + var readBuffer = [UInt8](repeating: 0, count: 8192) + while !isCancelled { + let count = read(fd, &readBuffer, readBuffer.count) + if count > 0 { + buffer.append(contentsOf: readBuffer[0.. BrainBusEvent { + BrainBusEvent(type: .queueDepth, queueDepth: depth) + } + + static func enrichStatus(_ status: String) -> BrainBusEvent { + BrainBusEvent(type: .enrichStatus, enrichStatus: status) + } + + static func lastChunkID(_ chunkID: String) -> BrainBusEvent { + BrainBusEvent(type: .lastChunkID, lastChunkID: chunkID) + } + + static func dbBusy(_ busy: Bool) -> BrainBusEvent { + BrainBusEvent(type: .dbBusy, dbBusy: busy) + } + + static func healthTick(openConnections: Int) -> BrainBusEvent { + BrainBusEvent(type: .healthTick, openConnections: openConnections) + } + + func withSequence(_ sequence: Int, generatedAt: Date = Date()) -> BrainBusEvent { + BrainBusEvent( + type: type, + sequence: sequence, + generatedAt: generatedAt, + queueDepth: queueDepth, + enrichStatus: enrichStatus, + lastChunkID: lastChunkID, + dbBusy: dbBusy, + openConnections: openConnections + ) + } + + private init( + type: BrainBusEventType, + sequence: Int = 0, + generatedAt: Date = Date(), + queueDepth: Int? = nil, + enrichStatus: String? = nil, + lastChunkID: String? = nil, + dbBusy: Bool? = nil, + openConnections: Int? = nil + ) { + self.type = type + self.sequence = sequence + self.generatedAt = generatedAt + self.queueDepth = queueDepth + self.enrichStatus = enrichStatus + self.lastChunkID = lastChunkID + self.dbBusy = dbBusy + self.openConnections = openConnections + } +} + +final class BrainBusEventHub: @unchecked Sendable { + typealias SubscriptionID = UUID + typealias EventWriter = @Sendable (BrainBusEvent) -> Bool + + private struct Subscriber { + let writer: EventWriter + let drainQueue: DispatchQueue + var buffer: [BrainBusEvent] = [] + var isDraining = false + } + + private let queue = DispatchQueue(label: "com.brainlayer.brainbar.brain-bus") + private let bufferCapacity: Int + private var nextSequence = 0 + private var subscribers: [SubscriptionID: Subscriber] = [:] + private var heartbeatTimer: DispatchSourceTimer? + + init(bufferCapacity: Int = 64, heartbeatInterval: TimeInterval? = 5) { + self.bufferCapacity = max(1, bufferCapacity) + if let heartbeatInterval { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + heartbeatInterval, repeating: heartbeatInterval, leeway: .milliseconds(100)) + timer.setEventHandler { [weak self] in + self?.publishOnQueue(.healthTick(openConnections: self?.subscribers.count ?? 0)) + } + timer.resume() + heartbeatTimer = timer + } + } + + deinit { + heartbeatTimer?.cancel() + } + + func subscribe(_ writer: @escaping EventWriter) -> SubscriptionID { + let id = UUID() + queue.sync { + subscribers[id] = Subscriber( + writer: writer, + drainQueue: DispatchQueue(label: "com.brainlayer.brainbar.brain-bus.subscriber.\(id.uuidString)") + ) + } + return id + } + + func unsubscribe(_ id: SubscriptionID) { + queue.async { + self.subscribers.removeValue(forKey: id) + } + } + + func publish(_ event: BrainBusEvent) { + queue.async { + self.publishOnQueue(event) + } + } + + private func publishOnQueue(_ event: BrainBusEvent) { + nextSequence += 1 + let sequenced = event.withSequence(nextSequence) + for id in subscribers.keys { + enqueue(sequenced, for: id) + } + } + + private func enqueue(_ event: BrainBusEvent, for id: SubscriptionID) { + guard var subscriber = subscribers[id] else { return } + if subscriber.buffer.count >= bufferCapacity { + subscriber.buffer.removeFirst() + } + subscriber.buffer.append(event) + let shouldStartDrain = !subscriber.isDraining + if shouldStartDrain { + subscriber.isDraining = true + } + subscribers[id] = subscriber + if shouldStartDrain { + drainNext(for: id) + } + } + + private func drainNext(for id: SubscriptionID) { + guard var subscriber = subscribers[id] else { return } + guard !subscriber.buffer.isEmpty else { + subscriber.isDraining = false + subscribers[id] = subscriber + return + } + let event = subscriber.buffer.removeFirst() + let writer = subscriber.writer + let drainQueue = subscriber.drainQueue + subscribers[id] = subscriber + + drainQueue.async { [weak self] in + let keepOpen = writer(event) + self?.queue.async { [weak self] in + guard keepOpen else { + self?.subscribers.removeValue(forKey: id) + return + } + self?.drainNext(for: id) + } + } + } +} diff --git a/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift b/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift index 0991010b..df62b96d 100644 --- a/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift +++ b/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift @@ -29,18 +29,21 @@ final class StatsCollector: ObservableObject { private let database: BrainDatabase private let daemonMonitor: DaemonHealthMonitor private let agentActivityMonitor: AgentActivityMonitor - private var pollTask: Task? + private let brainBusEvents: BrainBusEventSource? + private var brainBusTask: Task? private var isRunning = false private var lastDataVersion: Int? init( dbPath: String, daemonMonitor: DaemonHealthMonitor, - agentActivityMonitor: AgentActivityMonitor = AgentActivityMonitor() + agentActivityMonitor: AgentActivityMonitor = AgentActivityMonitor(), + brainBusEvents: BrainBusEventSource? = nil ) { self.database = BrainDatabase(path: dbPath) self.daemonMonitor = daemonMonitor self.agentActivityMonitor = agentActivityMonitor + self.brainBusEvents = brainBusEvents self.stats = DashboardStats( chunkCount: 0, enrichedChunkCount: 0, @@ -61,19 +64,24 @@ final class StatsCollector: ObservableObject { guard !isRunning else { return } isRunning = true installDarwinObserver() - refresh(force: true) - pollTask = Task { [weak self] in - while let self, !Task.isCancelled { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { break } - self.pollForChanges() + if let brainBusEvents { + let eventStream = brainBusEvents.events() + brainBusTask = Task { [weak self] in + for await event in eventStream { + guard !Task.isCancelled else { break } + await MainActor.run { + self?.handleBrainBusEvent(event) + } + } } + } else { + refresh(force: true) } } func stop() { - pollTask?.cancel() - pollTask = nil + brainBusTask?.cancel() + brainBusTask = nil if isRunning { removeDarwinObserver() } @@ -120,8 +128,15 @@ final class StatsCollector: ObservableObject { refresh(force: false) } - private func pollForChanges() { - refresh(force: false) + private func handleBrainBusEvent(_ event: BrainBusEvent) { + switch event.type { + case .healthTick: + daemon = daemonMonitor.sample() + agentActivity = agentActivityMonitor.sample() + state = PipelineState.derive(daemon: daemon, stats: stats) + case .queueDepth, .enrichStatus, .lastChunkID, .dbBusy: + refresh(force: false) + } } private func installDarwinObserver() { diff --git a/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift b/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift new file mode 100644 index 00000000..debeb65b --- /dev/null +++ b/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift @@ -0,0 +1,78 @@ +import XCTest +@testable import BrainBar + +final class BrainBusEventHubTests: XCTestCase { + func testSubscribeReceivesThreeStateChangesInOrderWithinLatencyBudget() throws { + let hub = BrainBusEventHub(bufferCapacity: 8, heartbeatInterval: nil) + let received = LockedBrainBusEvents() + let allReceived = XCTestExpectation(description: "received three brain bus events") + + let subscription = hub.subscribe { event in + received.append(event) + if received.count == 3 { + allReceived.fulfill() + } + return true + } + defer { hub.unsubscribe(subscription) } + + let startedAt = DispatchTime.now() + hub.publish(.queueDepth(2)) + hub.publish(.enrichStatus("running")) + hub.publish(.lastChunkID("chunk-3")) + + wait(for: [allReceived], timeout: 1.0) + let elapsedMillis = Double(DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000 + + XCTAssertLessThan(elapsedMillis, 50) + XCTAssertEqual(received.snapshot().map(\.type), [.queueDepth, .enrichStatus, .lastChunkID]) + } + + func testBackpressureDropsOldestEventsWithoutStallingPublisher() throws { + let hub = BrainBusEventHub(bufferCapacity: 3, heartbeatInterval: nil) + let received = LockedBrainBusEvents() + let sawLatest = XCTestExpectation(description: "slow subscriber eventually receives latest event") + + let subscription = hub.subscribe { event in + Thread.sleep(forTimeInterval: 0.02) + received.append(event) + if event.sequence == 20 { + sawLatest.fulfill() + } + return true + } + defer { hub.unsubscribe(subscription) } + + let startedAt = DispatchTime.now() + for index in 1...20 { + hub.publish(.healthTick(openConnections: index)) + } + let publishElapsedMillis = Double(DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000 + + XCTAssertLessThan(publishElapsedMillis, 20) + wait(for: [sawLatest], timeout: 1.0) + + let delivered = received.snapshot() + XCTAssertLessThan(delivered.count, 20) + XCTAssertEqual(delivered.last?.sequence, 20) + } +} + +private final class LockedBrainBusEvents: @unchecked Sendable { + private let lock = NSLock() + private var events: [BrainBusEvent] = [] + + var count: Int { + lock.withLock { events.count } + } + + func append(_ event: BrainBusEvent) { + lock.withLock { + events.append(event) + } + } + + func snapshot() -> [BrainBusEvent] { + lock.withLock { events } + } +} diff --git a/brain-bar/Tests/BrainBarTests/DashboardTests.swift b/brain-bar/Tests/BrainBarTests/DashboardTests.swift index 0a30bbf8..cfd2e4cf 100644 --- a/brain-bar/Tests/BrainBarTests/DashboardTests.swift +++ b/brain-bar/Tests/BrainBarTests/DashboardTests.swift @@ -255,6 +255,24 @@ final class DashboardTests: XCTestCase { XCTAssertEqual(collector.stats.chunkCount, 1) } + @MainActor + func testStatsCollectorSubscribesToBrainBusWithoutPollingDelay() { + let eventSource = RecordingBrainBusEventSource() + let collector = StatsCollector( + dbPath: tempDBPath, + daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + brainBusEvents: eventSource + ) + defer { collector.stop() } + + let startedAt = DispatchTime.now() + collector.start() + let elapsedMillis = Double(DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000 + + XCTAssertEqual(eventSource.streamRequestCount, 1) + XCTAssertLessThan(elapsedMillis, 100) + } + func testPipelineStateTreatsMissingDaemonSnapshotAsDegraded() { let stats = DashboardStats( chunkCount: 10, @@ -387,3 +405,19 @@ final class DashboardTests: XCTestCase { XCTAssertTrue(viewController.isViewLoaded) } } + +private final class RecordingBrainBusEventSource: BrainBusEventSource, @unchecked Sendable { + private let lock = NSLock() + private var requests = 0 + + var streamRequestCount: Int { + lock.withLock { requests } + } + + func events() -> AsyncStream { + lock.withLock { + requests += 1 + } + return AsyncStream { _ in } + } +} diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index b7dafcd2..22e7803d 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -151,6 +151,38 @@ final class SocketIntegrationTests: XCTestCase { } } + func testWatchBrainBusStreamsStoreEventsOverRawUnixSocket() throws { + let watchFD = try connectClient() + defer { close(watchFD) } + + try sendRawLineJSON(on: watchFD, object: [ + "jsonrpc": "2.0", + "id": 50, + "method": "watch-brain-bus", + ]) + _ = try readBrainBusEvent(fd: watchFD, matching: "health_tick") + + let storeResponse = try sendMCPRequest([ + "jsonrpc": "2.0", + "id": 51, + "method": "tools/call", + "params": [ + "name": "brain_store", + "arguments": [ + "content": "Brain bus stream integration", + "tags": ["agent-message"] + ] as [String: Any] + ] + ]) + XCTAssertNil(storeResponse["error"]) + + let event = try readBrainBusEvent(fd: watchFD, matching: "last_chunk_id") + XCTAssertEqual(event["method"] as? String, "notifications/brain-bus") + let params = try XCTUnwrap(event["params"] as? [String: Any]) + XCTAssertEqual(params["type"] as? String, "last_chunk_id") + XCTAssertFalse((params["last_chunk_id"] as? String ?? "").isEmpty) + } + // MARK: - MCP tools/call brain_search over socket func testMCPBrainSearchOverSocket() throws { @@ -889,6 +921,35 @@ final class SocketIntegrationTests: XCTestCase { throw NSError(domain: "test", code: 4, userInfo: [NSLocalizedDescriptionKey: "Timeout reading raw line response"]) } + private func readBrainBusEvent(fd: Int32, matching type: String, timeout: TimeInterval = 5.0) throws -> [String: Any] { + let deadline = Date().addingTimeInterval(timeout) + var buffer = Data() + var readBuf = [UInt8](repeating: 0, count: 65536) + while Date() < deadline { + while let newlineIndex = buffer.firstIndex(of: 0x0A) { + let line = Data(buffer[.. 0 { + buffer.append(contentsOf: readBuf[0.. [String: Any] { return try readMCPMessages(fd: fd, expectedCount: 1, timeout: timeout).first ?? [:] } From 740e90c1b8420cb3c2ca3d22548b54d065f4f68c Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 12:53:10 +0300 Subject: [PATCH 10/28] refactor: use NSStatusItem popover shell --- brain-bar/Sources/BrainBar/BrainBarApp.swift | 94 +++++++--------- .../BrainBarStatusPopoverController.swift | 104 ++++++++++++++++++ ...BrainBarStatusPopoverControllerTests.swift | 53 +++++++++ 3 files changed, 197 insertions(+), 54 deletions(-) create mode 100644 brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift create mode 100644 brain-bar/Tests/BrainBarTests/BrainBarStatusPopoverControllerTests.swift diff --git a/brain-bar/Sources/BrainBar/BrainBarApp.swift b/brain-bar/Sources/BrainBar/BrainBarApp.swift index 2a66001a..209ecc14 100644 --- a/brain-bar/Sources/BrainBar/BrainBarApp.swift +++ b/brain-bar/Sources/BrainBar/BrainBarApp.swift @@ -7,6 +7,19 @@ enum BrainBarAppSupport { static func hotkeyPermissionFailureMessage(permissions: HotkeyPermissionStatus) -> String { "BrainBar could not start the fallback hotkey listener. Enable \(permissions.missingPermissionsMessage) in System Settings. The CGEventTap fallback requires both Input Monitoring and Accessibility." } + + @MainActor + static func makeStatsCollector( + dbPath: String, + targetPID: pid_t, + brainBusEvents: BrainBusEventSource? = BrainBusClient() + ) -> StatsCollector { + StatsCollector( + dbPath: dbPath, + daemonMonitor: DaemonHealthMonitor(targetPID: targetPID), + brainBusEvents: brainBusEvents + ) + } } @MainActor @@ -15,6 +28,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private static let menuBarWindowAutosaveKey = "NSWindow Frame BrainBarMenuBarExtraWindow" private var server: BrainBarServer? + private var statusPopoverController: BrainBarStatusPopoverController? private var legacyStatusItem: NSStatusItem? private var legacyPopover: NSPopover? private var collector: StatsCollector? @@ -47,11 +61,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { startHotkeyFileWatcher() - if launchMode == .menuBarWindow { - UserDefaults.standard.removeObject(forKey: Self.menuBarWindowAutosaveKey) - dashboardPanel = BrainBarDashboardPanelController(runtime: runtime) - } - let runningInstances = NSRunningApplication.runningApplications( withBundleIdentifier: Bundle.main.bundleIdentifier ?? "com.brainlayer.BrainBar" ) @@ -69,7 +78,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.configureQuickCaptureHotkey() } - if launchMode == .legacyStatusItem { + if launchMode == .menuBarWindow { + statusPopoverController = BrainBarStatusPopoverController(runtime: runtime) + } else if launchMode == .legacyStatusItem { createLegacyStatusItem() } @@ -88,9 +99,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.sharedDatabase = database self.configureQuickCapture(database: database) self.runtime.install( - collector: self.collector ?? StatsCollector( + collector: self.collector ?? BrainBarAppSupport.makeStatsCollector( dbPath: dbPath, - daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + targetPID: ProcessInfo.processInfo.processIdentifier, brainBusEvents: BrainBusClient() ), injectionStore: self.injectionStore, @@ -100,9 +111,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } - let collector = StatsCollector( + let collector = BrainBarAppSupport.makeStatsCollector( dbPath: dbPath, - daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + targetPID: ProcessInfo.processInfo.processIdentifier, brainBusEvents: BrainBusClient() ) let injectionStore = try? InjectionStore(databasePath: dbPath) @@ -125,6 +136,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { menuBarWindowObservers.forEach(NotificationCenter.default.removeObserver) menuBarWindowObservers.removeAll() + statusPopoverController?.stop() + statusPopoverController = nil menuBarWindowSyncTask?.cancel() menuBarWindowSyncTask = nil hotkeyFileWatcher?.cancel() @@ -149,7 +162,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } runtime.presentQuickAction(.search) - showMenuBarWindow(nil) + statusPopoverController?.show(nil) } func showQuickCapturePanel() { @@ -159,7 +172,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } runtime.presentQuickAction(.capture) - showMenuBarWindow(nil) + statusPopoverController?.show(nil) } private func configureRuntimeCallbacks() { @@ -238,6 +251,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } + if launchMode == .menuBarWindow, let statusPopoverController { + statusPopoverController.toggle(sender) + return + } + if let dashboardPanel { dashboardPanel.toggle() return @@ -286,10 +304,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } + if let statusPopoverController { + statusPopoverController.show(nil) + return + } + dashboardPanel?.show() } private func showMenuBarWindow(_ sender: Any?) { + if launchMode == .menuBarWindow, let statusPopoverController { + statusPopoverController.show(sender) + return + } + if launchMode == .menuBarWindow, let dashboardPanel { dashboardPanel.show() return @@ -861,26 +889,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @main struct BrainBarApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - private let launchMode = BrainBarLaunchMode.resolve() var body: some Scene { - MenuBarExtra(isInserted: .constant(launchMode == .menuBarWindow)) { - Button("Open Dashboard") { - appDelegate.showDashboardPanel() - } - - Button("Search BrainLayer") { - appDelegate.showSearchPanel() - } - - Button("Capture Note") { - appDelegate.showQuickCapturePanel() - } - } label: { - BrainBarMenuBarLabel(runtime: appDelegate.runtime) - } - .menuBarExtraStyle(.menu) - Settings { EmptyView() } @@ -902,27 +912,3 @@ struct BrainBarApp: App { } } } - -private struct BrainBarMenuBarLabel: View { - @ObservedObject var runtime: BrainBarRuntime - - var body: some View { - if let collector = runtime.collector { - let livePresentation = BrainBarLivePresentation.derive(stats: collector.stats) - HStack(spacing: 6) { - Image(systemName: "brain") - Image( - nsImage: SparklineRenderer.render( - state: collector.state, - values: collector.stats.recentEnrichmentBuckets, - size: NSSize(width: 22, height: 12), - accentColor: livePresentation.accentColor - ) - ) - .interpolation(.high) - } - } else { - Image(systemName: "brain") - } - } -} diff --git a/brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift b/brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift new file mode 100644 index 00000000..7a13da72 --- /dev/null +++ b/brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift @@ -0,0 +1,104 @@ +import AppKit +import Combine +import SwiftUI + +@MainActor +final class BrainBarStatusPopoverController: NSObject { + static let contentSize = NSSize(width: 900, height: 640) + + let statusItemForTesting: NSStatusItem + let popoverForTesting: NSPopover + + private let runtime: BrainBarRuntime + private var runtimeCancellables: Set = [] + private var collectorCancellables: Set = [] + + init(runtime: BrainBarRuntime) { + self.runtime = runtime + statusItemForTesting = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + popoverForTesting = NSPopover() + super.init() + + configureStatusItem() + prewarmPopover() + bindRuntime() + } + + func toggle(_ sender: Any?) { + if popoverForTesting.isShown { + popoverForTesting.performClose(sender) + } else { + show(sender) + } + } + + func show(_ sender: Any?) { + guard let button = statusItemForTesting.button else { return } + popoverForTesting.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popoverForTesting.contentViewController?.view.window?.makeKey() + } + + func close(_ sender: Any?) { + popoverForTesting.performClose(sender) + } + + func stop() { + close(nil) + NSStatusBar.system.removeStatusItem(statusItemForTesting) + } + + private func configureStatusItem() { + guard let button = statusItemForTesting.button else { return } + button.image = NSImage(systemSymbolName: "brain", accessibilityDescription: "BrainBar") + button.target = self + button.action = #selector(toggleFromStatusItem(_:)) + button.toolTip = "BrainBar" + } + + private func prewarmPopover() { + popoverForTesting.behavior = .transient + popoverForTesting.contentSize = Self.contentSize + + let hosting = NSHostingController( + rootView: BrainBarWindowRootView(runtime: runtime, managesWindowFrame: false) + .frame(width: Self.contentSize.width, height: Self.contentSize.height) + ) + _ = hosting.view + popoverForTesting.contentViewController = hosting + } + + private func bindRuntime() { + runtime.$collector + .receive(on: RunLoop.main) + .sink { [weak self] collector in + self?.bindCollector(collector) + } + .store(in: &runtimeCancellables) + } + + private func bindCollector(_ collector: StatsCollector?) { + collectorCancellables.removeAll() + guard let collector else { return } + + Publishers.CombineLatest(collector.$stats, collector.$state) + .receive(on: RunLoop.main) + .sink { [weak self] stats, state in + self?.renderStatusIcon(stats: stats, state: state) + } + .store(in: &collectorCancellables) + } + + private func renderStatusIcon(stats: BrainDatabase.DashboardStats, state: PipelineState) { + let livePresentation = BrainBarLivePresentation.derive(stats: stats) + statusItemForTesting.button?.image = SparklineRenderer.render( + state: state, + values: stats.recentEnrichmentBuckets, + size: NSSize(width: 22, height: 12), + accentColor: livePresentation.accentColor + ) + } + + @objc private func toggleFromStatusItem(_ sender: Any?) { + toggle(sender) + } +} diff --git a/brain-bar/Tests/BrainBarTests/BrainBarStatusPopoverControllerTests.swift b/brain-bar/Tests/BrainBarTests/BrainBarStatusPopoverControllerTests.swift new file mode 100644 index 00000000..8b4846be --- /dev/null +++ b/brain-bar/Tests/BrainBarTests/BrainBarStatusPopoverControllerTests.swift @@ -0,0 +1,53 @@ +import AppKit +import XCTest +@testable import BrainBar + +@MainActor +final class BrainBarStatusPopoverControllerTests: XCTestCase { + func testControllerPrewarmsVariableLengthStatusItemPopover() { + let runtime = BrainBarRuntime(launchMode: .menuBarWindow) + let controller = BrainBarStatusPopoverController(runtime: runtime) + defer { controller.stop() } + + XCTAssertEqual(controller.statusItemForTesting.length, NSStatusItem.variableLength) + XCTAssertEqual(controller.popoverForTesting.behavior, NSPopover.Behavior.transient) + XCTAssertNotNil(controller.popoverForTesting.contentViewController) + XCTAssertTrue(controller.popoverForTesting.contentViewController?.isViewLoaded == true) + } + + func testAppSupportCollectorFactoryWiresBrainBusEvents() { + let tempDBPath = NSTemporaryDirectory() + "brainbar-status-popover-\(UUID().uuidString).db" + let eventSource = RecordingBrainBusEventSource() + let collector = BrainBarAppSupport.makeStatsCollector( + dbPath: tempDBPath, + targetPID: ProcessInfo.processInfo.processIdentifier, + brainBusEvents: eventSource + ) + defer { + collector.stop() + try? FileManager.default.removeItem(atPath: tempDBPath) + try? FileManager.default.removeItem(atPath: tempDBPath + "-wal") + try? FileManager.default.removeItem(atPath: tempDBPath + "-shm") + } + + collector.start() + + XCTAssertEqual(eventSource.streamRequestCount, 1) + } +} + +private final class RecordingBrainBusEventSource: BrainBusEventSource, @unchecked Sendable { + private let lock = NSLock() + private var requests = 0 + + var streamRequestCount: Int { + lock.withLock { requests } + } + + func events() -> AsyncStream { + lock.withLock { + requests += 1 + } + return AsyncStream { _ in } + } +} From 3d8ec7872f1877182d32f440788d714560526883 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 12:52:49 +0300 Subject: [PATCH 11/28] fix: prevent SIGPIPE from hybrid helper socket writes --- .../BrainBar/HybridSearchHelperClient.swift | 13 +++++++++++++ .../HybridSearchHelperClientTests.swift | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index 82a56141..369b4186 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -244,6 +244,12 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen guard fd >= 0 else { throw HybridSearchHelperError.socket(errno) } + do { + try Self.configureNoSigpipe(fd: fd) + } catch { + close(fd) + throw error + } var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) @@ -281,6 +287,13 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen throw HybridSearchHelperError.connect(lastErrno) } + static func configureNoSigpipe(fd: Int32) throws { + var nosigpipe: Int32 = 1 + if setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, socklen_t(MemoryLayout.size)) != 0 { + throw HybridSearchHelperError.configureSocket(errno) + } + } + static func configureSocketTimeouts(fd: Int32, timeout: TimeInterval) throws { let boundedTimeout = max(timeout, 0.001) let seconds = Int(boundedTimeout) diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift index ef95c4a3..e5abbd57 100644 --- a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift +++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift @@ -85,4 +85,20 @@ final class HybridSearchHelperClientTests: XCTestCase { XCTAssertTrue(errno == EAGAIN || errno == EWOULDBLOCK) XCTAssertLessThan(elapsed, 1.0) } + + func testConfigureNoSigpipeAcceptsOpenUnixSocket() throws { + var fds: [Int32] = [0, 0] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) + defer { + close(fds[0]) + close(fds[1]) + } + + try HybridSearchHelperClient.configureNoSigpipe(fd: fds[0]) + + var value: Int32 = 0 + var length = socklen_t(MemoryLayout.size) + XCTAssertEqual(getsockopt(fds[0], SOL_SOCKET, SO_NOSIGPIPE, &value, &length), 0) + XCTAssertEqual(value, 1) + } } From ea9064f1fb45f9ab06c6ae8358575739cd43111b Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 06:01:28 +0300 Subject: [PATCH 12/28] fix: route BrainBar search through Python hybrid helper --- .../Sources/BrainBar/BrainBarServer.swift | 23 +- .../BrainBar/HybridSearchHelperClient.swift | 265 ++++++++++++++++++ brain-bar/Sources/BrainBar/MCPRouter.swift | 77 ++++- .../BrainBarReliabilityTests.swift | 1 + .../BrainBarStartupRecoveryTests.swift | 1 + .../Tests/BrainBarTests/MCPRouterTests.swift | 119 ++++++++ .../SocketIntegrationTests.swift | 80 +++++- src/brainlayer/brainbar_hybrid_helper.py | 180 ++++++++++++ tests/test_brainbar_hybrid_helper.py | 55 ++++ 9 files changed, 782 insertions(+), 19 deletions(-) create mode 100644 brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift create mode 100644 src/brainlayer/brainbar_hybrid_helper.py create mode 100644 tests/test_brainbar_hybrid_helper.py diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift index 02366bdc..cc0a77e4 100644 --- a/brain-bar/Sources/BrainBar/BrainBarServer.swift +++ b/brain-bar/Sources/BrainBar/BrainBarServer.swift @@ -98,6 +98,9 @@ final class BrainBarServer: @unchecked Sendable { private var clients: [Int32: ClientState] = [:] private var router: MCPRouter! private var database: BrainDatabase! + private var hybridSearchHelperClient: HybridSearchHelperClient? + private let providedHybridSearchClient: HybridSearchClientProtocol? + private let enableHybridSearchHelper: Bool private var databaseRetryWorkItem: DispatchWorkItem? private var lastDatabaseRetryDelayMillis: UInt64? private var databaseOpenInProgress = false @@ -141,12 +144,16 @@ final class BrainBarServer: @unchecked Sendable { socketPath: String? = nil, dbPath: String? = nil, database: BrainDatabase? = nil, + hybridSearchClient: HybridSearchClientProtocol? = nil, + enableHybridSearchHelper: Bool = true, databaseRecoveryPolicy: DatabaseRecoveryPolicy = DatabaseRecoveryPolicy(), instanceLockPath: String? = nil ) { self.socketPath = socketPath ?? Self.defaultSocketPath() self.dbPath = dbPath ?? Self.defaultDBPath() providedDatabase = database + providedHybridSearchClient = hybridSearchClient + self.enableHybridSearchHelper = enableHybridSearchHelper self.databaseRecoveryPolicy = databaseRecoveryPolicy self.instanceLockPath = instanceLockPath ?? Self.defaultInstanceLockPath(socketPath: self.socketPath) } @@ -197,9 +204,21 @@ final class BrainBarServer: @unchecked Sendable { return } + let hybridClient: HybridSearchClientProtocol? + if let providedHybridSearchClient { + hybridClient = providedHybridSearchClient + } else if providedDatabase == nil && enableHybridSearchHelper { + let client = HybridSearchHelperClient(dbPath: dbPath) + client.start() + hybridSearchHelperClient = client + hybridClient = client + } else { + hybridClient = nil + } + // 1. Create router FIRST (no DB dependency). // initialize + tools/list work without a database. - router = MCPRouter() + router = MCPRouter(hybridSearchClient: hybridClient) // 2. Bind socket BEFORE database init. // After a restart the socket must exist before Claude Code tries @@ -542,6 +561,8 @@ final class BrainBarServer: @unchecked Sendable { database?.close() } database = nil + hybridSearchHelperClient?.stop() + hybridSearchHelperClient = nil databaseOpenInProgress = false instanceLock?.release() instanceLock = nil diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift new file mode 100644 index 00000000..c19395b9 --- /dev/null +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -0,0 +1,265 @@ +import Foundation + +struct HybridSearchResponse { + let text: String + let metadata: [String: Any] + + init(text: String, metadata: [String: Any] = [:]) { + self.text = text + self.metadata = metadata + } +} + +protocol HybridSearchClientProtocol: AnyObject, Sendable { + func search(arguments: [String: Any]) throws -> HybridSearchResponse +} + +final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sendable { + private let socketPath: String + private let dbPath: String + private let pythonExecutable: String + private let environment: [String: String] + private let queue = DispatchQueue(label: "com.brainlayer.brainbar.hybrid-helper") + private var process: Process? + + init( + socketPath: String? = nil, + dbPath: String, + pythonExecutable: String? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment + ) { + self.socketPath = socketPath ?? Self.defaultSocketPath() + self.dbPath = dbPath + self.pythonExecutable = pythonExecutable ?? Self.resolvePythonExecutable(environment: environment) + self.environment = environment + } + + static func defaultSocketPath() -> String { + "/tmp/brainbar-hybrid-\(ProcessInfo.processInfo.processIdentifier).sock" + } + + static func resolvePythonExecutable(environment: [String: String]) -> String { + if let explicit = environment["BRAINBAR_PYTHON"], !explicit.isEmpty { + return explicit + } + if let virtualEnv = environment["VIRTUAL_ENV"], !virtualEnv.isEmpty { + let candidate = "\(virtualEnv)/bin/python" + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + let repoCandidate = "/Users/etanheyman/Gits/brainlayer/.venv/bin/python" + if FileManager.default.isExecutableFile(atPath: repoCandidate) { + return repoCandidate + } + return "/usr/bin/env" + } + + func start() { + queue.sync { + startLocked() + } + } + + func stop() { + queue.sync { + process?.terminate() + process = nil + unlink(socketPath) + } + } + + func search(arguments: [String: Any]) throws -> HybridSearchResponse { + try queue.sync { + startLocked() + return try send(arguments: arguments) + } + } + + private func startLocked() { + if let process, process.isRunning { + return + } + + unlink(socketPath) + + let proc = Process() + if pythonExecutable == "/usr/bin/env" { + proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") + proc.arguments = [ + "python3", + "-m", + "brainlayer.brainbar_hybrid_helper", + "--socket-path", + socketPath, + "--db-path", + dbPath + ] + } else { + proc.executableURL = URL(fileURLWithPath: pythonExecutable) + proc.arguments = [ + "-m", + "brainlayer.brainbar_hybrid_helper", + "--socket-path", + socketPath, + "--db-path", + dbPath + ] + } + + var env = environment + env["BRAINLAYER_DB"] = dbPath + if env["PYTHONPATH"] == nil || env["PYTHONPATH"]?.isEmpty == true { + env["PYTHONPATH"] = "/Users/etanheyman/Gits/brainlayer/src" + } + proc.environment = env + proc.standardInput = Pipe() + proc.standardOutput = Pipe() + proc.standardError = FileHandle.standardError + + do { + try proc.run() + process = proc + NSLog("[BrainBar] Hybrid search helper started pid=%d socket=%@", proc.processIdentifier, socketPath) + } catch { + NSLog("[BrainBar] Failed to start hybrid search helper: %@", String(describing: error)) + process = nil + } + } + + private func send(arguments: [String: Any]) throws -> HybridSearchResponse { + let fd = try connectWithRetry() + defer { close(fd) } + + let payload: [String: Any] = [ + "method": "brain_search", + "arguments": arguments + ] + let data = try JSONSerialization.data(withJSONObject: payload) + var framed = data + framed.append(0x0A) + try writeAll(fd: fd, data: framed) + let responseData = try readLine(fd: fd) + let decoded = try JSONSerialization.jsonObject(with: responseData) as? [String: Any] + guard let decoded else { + throw HybridSearchHelperError.invalidResponse + } + if let ok = decoded["ok"] as? Bool, !ok { + let message = decoded["error"] as? String ?? "unknown helper error" + throw HybridSearchHelperError.helperError(message) + } + guard let text = decoded["text"] as? String else { + throw HybridSearchHelperError.invalidResponse + } + let metadata = decoded["metadata"] as? [String: Any] ?? [:] + return HybridSearchResponse(text: text, metadata: metadata) + } + + private func connectWithRetry() throws -> Int32 { + var lastErrno: Int32 = 0 + for attempt in 0..<50 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw HybridSearchHelperError.socket(errno) + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = socketPath.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + close(fd) + throw HybridSearchHelperError.socketPathTooLong(socketPath) + } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in + pathBytes.withUnsafeBufferPointer { src in + _ = memcpy(dest, src.baseAddress!, src.count) + } + } + } + + let rc = withUnsafePointer(to: &addr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { ptr in + connect(fd, ptr, socklen_t(MemoryLayout.size)) + } + } + if rc == 0 { + return fd + } + lastErrno = errno + close(fd) + usleep(useconds_t(min(50_000 + attempt * 10_000, 250_000))) + } + throw HybridSearchHelperError.connect(lastErrno) + } + + private func writeAll(fd: Int32, data: Data) throws { + try data.withUnsafeBytes { rawBuffer in + guard let base = rawBuffer.baseAddress else { return } + var written = 0 + while written < data.count { + let count = write(fd, base.advanced(by: written), data.count - written) + if count < 0 { + if errno == EINTR { continue } + throw HybridSearchHelperError.write(errno) + } + if count == 0 { + throw HybridSearchHelperError.write(EPIPE) + } + written += count + } + } + } + + private func readLine(fd: Int32) throws -> Data { + var result = Data() + var byte = UInt8(0) + while true { + let count = read(fd, &byte, 1) + if count < 0 { + if errno == EINTR { continue } + throw HybridSearchHelperError.read(errno) + } + if count == 0 { + break + } + if byte == 0x0A { + break + } + result.append(byte) + } + guard !result.isEmpty else { + throw HybridSearchHelperError.invalidResponse + } + return result + } +} + +enum HybridSearchHelperError: LocalizedError { + case socket(Int32) + case socketPathTooLong(String) + case connect(Int32) + case write(Int32) + case read(Int32) + case invalidResponse + case helperError(String) + + var errorDescription: String? { + switch self { + case .socket(let code): + return "hybrid helper socket failed: errno \(code)" + case .socketPathTooLong(let path): + return "hybrid helper socket path too long: \(path)" + case .connect(let code): + return "hybrid helper connect failed: errno \(code)" + case .write(let code): + return "hybrid helper write failed: errno \(code)" + case .read(let code): + return "hybrid helper read failed: errno \(code)" + case .invalidResponse: + return "hybrid helper returned an invalid response" + case .helperError(let message): + return "hybrid helper error: \(message)" + } + } +} diff --git a/brain-bar/Sources/BrainBar/MCPRouter.swift b/brain-bar/Sources/BrainBar/MCPRouter.swift index d38c58ea..31399d7a 100644 --- a/brain-bar/Sources/BrainBar/MCPRouter.swift +++ b/brain-bar/Sources/BrainBar/MCPRouter.swift @@ -33,6 +33,7 @@ final class MCPRouter: @unchecked Sendable { } private var database: BrainDatabase? + private let hybridSearchClient: HybridSearchClientProtocol? let entityCache = EntityCache() private static let defaultStringMaxLength = 256 private static let defaultStringArrayMaxItems = 100 @@ -59,6 +60,10 @@ final class MCPRouter: @unchecked Sendable { "tags": (maxItems: 100, itemMaxLength: 128) ] + init(hybridSearchClient: HybridSearchClientProtocol? = nil) { + self.hybridSearchClient = hybridSearchClient + } + /// Inject database for tool handlers + load entity cache. func setDatabase(_ db: BrainDatabase) { self.database = db @@ -271,24 +276,68 @@ final class MCPRouter: @unchecked Sendable { } } - let results = try db.search( - query: query, - limit: limit, - project: project, - source: source, - tag: tag, - importanceMin: importanceMin, - subscriberID: subscriberID, - unreadOnly: unreadOnly - ) - let typedResults = results.map(SearchResult.init(payload:)) - let textSection = TextFormatter.formatSearchResults(query: query, results: typedResults, total: typedResults.count) + let textSection: String + let metadata: [String: Any] + if let hybridSearchClient, subscriberID == nil, !unreadOnly { + let response = try hybridSearchClient.search(arguments: hybridSearchArguments( + query: query, + limit: limit, + project: project, + source: source, + tag: tag, + importanceMin: importanceMin, + detail: args["detail"] as? String + )) + textSection = response.text + metadata = response.metadata + } else { + let results = try db.search( + query: query, + limit: limit, + project: project, + source: source, + tag: tag, + importanceMin: importanceMin, + subscriberID: subscriberID, + unreadOnly: unreadOnly + ) + let typedResults = results.map(SearchResult.init(payload:)) + textSection = TextFormatter.formatSearchResults(query: query, results: typedResults, total: typedResults.count) + metadata = [:] + } // KG section goes before the envelope if kgSection.isEmpty { - return ToolOutput(text: textSection) + return ToolOutput(text: textSection, metadata: metadata) + } + return ToolOutput(text: kgSection + "\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + textSection, metadata: metadata) + } + + private func hybridSearchArguments( + query: String, + limit: Int, + project: String?, + source: String?, + tag: String?, + importanceMin: Double?, + detail: String? + ) -> [String: Any] { + var arguments: [String: Any] = [ + "query": query, + "num_results": limit, + "source": source ?? "all", + "detail": detail ?? "compact" + ] + if let project { + arguments["project"] = project + } + if let tag { + arguments["tag"] = tag + } + if let importanceMin { + arguments["importance_min"] = importanceMin } - return ToolOutput(text: kgSection + "\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + textSection) + return arguments } private func handleBrainStore(_ args: [String: Any]) throws -> ToolOutput { diff --git a/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift b/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift index a0f276ac..3db57d7b 100644 --- a/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift +++ b/brain-bar/Tests/BrainBarTests/BrainBarReliabilityTests.swift @@ -56,6 +56,7 @@ final class BrainBarReliabilityTests: XCTestCase { let server = BrainBarServer( socketPath: socketPath, dbPath: dbPath, + enableHybridSearchHelper: false, databaseRecoveryPolicy: .init( busyTimeoutMillis: 1_000, initialRetryDelayMillis: 25, diff --git a/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift b/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift index 8a4fbff6..a649ed5d 100644 --- a/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift +++ b/brain-bar/Tests/BrainBarTests/BrainBarStartupRecoveryTests.swift @@ -44,6 +44,7 @@ final class BrainBarStartupRecoveryTests: XCTestCase { let server = BrainBarServer( socketPath: socketPath, dbPath: dbPath, + enableHybridSearchHelper: false, databaseRecoveryPolicy: .init( busyTimeoutMillis: 50, initialRetryDelayMillis: 25, diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index 847fcddd..66eb01d9 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -292,6 +292,111 @@ final class MCPRouterTests: XCTestCase { XCTAssertEqual(response["id"] as? Int, 4) } + func testBrainSearchDelegatesNormalQueriesToHybridHelper() throws { + let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-helper-\(UUID().uuidString).db" + defer { try? FileManager.default.removeItem(atPath: tempDB) } + let db = BrainDatabase(path: tempDB) + defer { db.close() } + try db.insertChunk( + id: "fts-only-decoy", + content: "techgym speakers workshop decoy from the old Swift FTS path", + sessionId: "s1", + project: "brainlayer", + contentType: "assistant_text", + importance: 1 + ) + + let helper = RecordingHybridSearchClient( + response: HybridSearchResponse( + text: #""" +┌─ brain_search: "techgym speakers workshop" ─ 1 result +├─ [1] manual-a0b8a score:0.97 imp: 8 2026-05-16 +│ brainlayer │ Michal speakers workshop manual chunk +└─ +"""#, + metadata: ["structuredContent": ["query": "techgym speakers workshop"]] + ) + ) + let router = MCPRouter(hybridSearchClient: helper) + router.setDatabase(db) + + let response = router.handle([ + "jsonrpc": "2.0", + "id": 17, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": [ + "query": "techgym speakers workshop", + "num_results": 3, + "project": "brainlayer", + "source": "all", + "tag": "speakers-workshop", + "importance_min": 8, + "detail": "compact" + ] as [String: Any] + ] as [String: Any] + ]) + + let result = try XCTUnwrap(response["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + let text = content.first?["text"] as? String ?? "" + + XCTAssertTrue(text.contains("manual-a0b8a")) + XCTAssertFalse(text.contains("fts-only-decoy"), "Normal MCP brain_search must use the Python hybrid helper, not Swift FTS.") + XCTAssertEqual(helper.requests.count, 1) + XCTAssertEqual(helper.requests.first?["query"] as? String, "techgym speakers workshop") + XCTAssertEqual(helper.requests.first?["num_results"] as? Int, 3) + XCTAssertEqual(helper.requests.first?["project"] as? String, "brainlayer") + XCTAssertEqual(helper.requests.first?["source"] as? String, "all") + XCTAssertEqual(helper.requests.first?["tag"] as? String, "speakers-workshop") + XCTAssertEqual(helper.requests.first?["importance_min"] as? Double, 8) + XCTAssertEqual(helper.requests.first?["detail"] as? String, "compact") + XCTAssertNotNil(result["structuredContent"]) + } + + func testBrainSearchUnreadOnlyStaysOnBrainBarQueuePathWhenHybridHelperExists() throws { + let tempDB = NSTemporaryDirectory() + "brainbar-unread-helper-\(UUID().uuidString).db" + defer { try? FileManager.default.removeItem(atPath: tempDB) } + let db = BrainDatabase(path: tempDB) + defer { db.close() } + + try db.insertChunk(id: "read-1", content: "Agent message already delivered", sessionId: "s1", project: "test", contentType: "assistant_text", importance: 5, tags: "[\"agent-message\"]") + try db.insertChunk(id: "unread-1", content: "Agent message still unread", sessionId: "s2", project: "test", contentType: "assistant_text", importance: 5, tags: "[\"agent-message\"]") + _ = try db.upsertSubscription(agentID: "agent-1", tags: ["agent-message"]) + guard let readSeq = try db.chunkRowID(forChunkID: "read-1") else { + XCTFail("expected read-1 rowid") + return + } + try db.acknowledge(agentID: "agent-1", seq: readSeq) + + let helper = RecordingHybridSearchClient( + response: HybridSearchResponse(text: "helper should not be called") + ) + let router = MCPRouter(hybridSearchClient: helper) + router.setDatabase(db) + let response = router.handle([ + "jsonrpc": "2.0", + "id": 18, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": [ + "query": "agent message", + "subscriber_id": "agent-1", + "unread_only": true + ] as [String: Any] + ] as [String: Any] + ]) + + let result = response["result"] as? [String: Any] + let content = result?["content"] as? [[String: Any]] + let text = content?.first?["text"] as? String ?? "" + + XCTAssertTrue(text.contains("unread-1"), "Unread queue searches must preserve BrainBar subscriber cursor behavior.") + XCTAssertEqual(helper.requests.count, 0) + } + func testBrainMaintenanceRebuildTrigramToolReturnsProgressMetadata() throws { let tempDB = NSTemporaryDirectory() + "brainbar-maintenance-\(UUID().uuidString).db" defer { @@ -1577,3 +1682,17 @@ private func openSQLiteConnection(path: String) throws -> OpaquePointer { } return db } + +private final class RecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { + private let response: HybridSearchResponse + private(set) var requests: [[String: Any]] = [] + + init(response: HybridSearchResponse) { + self.response = response + } + + func search(arguments: [String: Any]) throws -> HybridSearchResponse { + requests.append(arguments) + return response + } +} diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index d6ea899f..affa99ad 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -10,11 +10,14 @@ import XCTest final class SocketIntegrationTests: XCTestCase { let testSocketPath = "/tmp/brainbar-test-\(ProcessInfo.processInfo.processIdentifier).sock" var server: BrainBarServer! + var db: BrainDatabase! + var tempDBPath: String! override func setUp() { super.setUp() - let tempDB = NSTemporaryDirectory() + "brainbar-integration-\(UUID().uuidString).db" - server = BrainBarServer(socketPath: testSocketPath, dbPath: tempDB) + tempDBPath = NSTemporaryDirectory() + "brainbar-integration-\(UUID().uuidString).db" + db = BrainDatabase(path: tempDBPath) + server = BrainBarServer(socketPath: testSocketPath, dbPath: tempDBPath, database: db) server.start() // Give server time to bind Thread.sleep(forTimeInterval: 0.2) @@ -22,6 +25,10 @@ final class SocketIntegrationTests: XCTestCase { override func tearDown() { server.stop() + db.close() + try? FileManager.default.removeItem(atPath: tempDBPath) + try? FileManager.default.removeItem(atPath: tempDBPath + "-wal") + try? FileManager.default.removeItem(atPath: tempDBPath + "-shm") super.tearDown() } @@ -216,6 +223,47 @@ final class SocketIntegrationTests: XCTestCase { ) } + func testMCPBrainSearchOverSocketUsesInjectedHybridHelper() throws { + server.stop() + let helper = SocketRecordingHybridSearchClient( + response: HybridSearchResponse( + text: #""" +┌─ brain_search: "techgym speakers workshop" ─ 1 result +├─ [1] manual-a0b8a score:0.97 imp: 8 2026-05-16 +└─ +"""# + ) + ) + server = BrainBarServer(socketPath: testSocketPath, dbPath: tempDBPath, database: db, hybridSearchClient: helper) + server.start() + Thread.sleep(forTimeInterval: 0.2) + + _ = try sendMCPRequest([ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": ["protocolVersion": "2024-11-05", "capabilities": [:] as [String: Any], + "clientInfo": ["name": "test", "version": "1.0"]] + ]) + + let response = try sendMCPRequest([ + "jsonrpc": "2.0", + "id": 19, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": ["query": "techgym speakers workshop", "num_results": 3, "source": "all"] as [String: Any] + ] + ]) + + let result = try XCTUnwrap(response["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + let text = content.first?["text"] as? String ?? "" + + XCTAssertTrue(text.contains("manual-a0b8a")) + XCTAssertEqual(helper.requests.count, 1) + XCTAssertEqual(helper.requests.first?["query"] as? String, "techgym speakers workshop") + XCTAssertEqual(helper.requests.first?["source"] as? String, "all") + } + func testMCPBrainSubscribeOverSocketReturnsCursorState() throws { _ = try sendMCPRequest([ "jsonrpc": "2.0", "id": 1, "method": "initialize", @@ -419,7 +467,9 @@ final class SocketIntegrationTests: XCTestCase { unsetenv("BRAINBAR_PENDING_STORES_PATH") } } - server = BrainBarServer(socketPath: testSocketPath, dbPath: dbPath) + let flushDB = BrainDatabase(path: dbPath) + defer { flushDB.close() } + server = BrainBarServer(socketPath: testSocketPath, dbPath: dbPath, database: flushDB) server.start() Thread.sleep(forTimeInterval: 0.2) @@ -761,7 +811,15 @@ final class SocketIntegrationTests: XCTestCase { func testRejectsOverlongSocketPath() throws { // sockaddr_un.sun_path is 104 bytes on macOS. A path > 104 should not crash. let longPath = "/tmp/" + String(repeating: "x", count: 200) + ".sock" - let longServer = BrainBarServer(socketPath: longPath, dbPath: NSTemporaryDirectory() + "test-long.db") + let longDBPath = NSTemporaryDirectory() + "test-long-\(UUID().uuidString).db" + let longDB = BrainDatabase(path: longDBPath) + defer { + longDB.close() + try? FileManager.default.removeItem(atPath: longDBPath) + try? FileManager.default.removeItem(atPath: longDBPath + "-wal") + try? FileManager.default.removeItem(atPath: longDBPath + "-shm") + } + let longServer = BrainBarServer(socketPath: longPath, dbPath: longDBPath, database: longDB) longServer.start() Thread.sleep(forTimeInterval: 0.2) @@ -976,3 +1034,17 @@ final class SocketIntegrationTests: XCTestCase { throw NSError(domain: "test", code: 5, userInfo: [NSLocalizedDescriptionKey: "Timeout reading line JSON"]) } } + +private final class SocketRecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { + private let response: HybridSearchResponse + private(set) var requests: [[String: Any]] = [] + + init(response: HybridSearchResponse) { + self.response = response + } + + func search(arguments: [String: Any]) throws -> HybridSearchResponse { + requests.append(arguments) + return response + } +} diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py new file mode 100644 index 00000000..04aea9e2 --- /dev/null +++ b/src/brainlayer/brainbar_hybrid_helper.py @@ -0,0 +1,180 @@ +"""Persistent BrainBar helper for canonical Python hybrid search. + +BrainBar owns the MCP socket and write queue. This helper owns the Python +retrieval stack so BrainBar search results use the same RRF hybrid path as the +Python MCP implementation without porting ranking logic to Swift. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import signal +import socket +import sys +from pathlib import Path +from typing import Any + + +def _json_safe(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, tuple): + return [_json_safe(item) for item in value] + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items()} + return str(value) + + +class HybridSearchHelper: + def __init__(self, socket_path: Path, db_path: Path): + self.socket_path = socket_path + self.db_path = db_path + self._stopped = False + + def warm(self) -> None: + os.environ["BRAINLAYER_DB"] = os.fspath(self.db_path) + from brainlayer.mcp._shared import _get_embedding_model, _get_vector_store + + _get_vector_store() + model = _get_embedding_model() + model.embed_query("brainbar hybrid helper warmup") + + def serve_forever(self) -> None: + if self.socket_path.exists(): + self.socket_path.unlink() + + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + server.bind(os.fspath(self.socket_path)) + os.chmod(self.socket_path, 0o600) + server.listen(16) + server.settimeout(0.25) + self.warm() + + while not self._stopped: + try: + conn, _ = server.accept() + except TimeoutError: + continue + except OSError: + if self._stopped: + break + raise + with conn: + self._handle_connection(conn) + finally: + server.close() + try: + self.socket_path.unlink() + except FileNotFoundError: + pass + + def stop(self, *_: object) -> None: + self._stopped = True + + def _handle_connection(self, conn: socket.socket) -> None: + try: + raw = self._read_line(conn) + request = json.loads(raw.decode("utf-8")) + response = self._handle_request(request) + except Exception as exc: + response = {"ok": False, "error": str(exc)} + + payload = json.dumps(_json_safe(response), separators=(",", ":")).encode("utf-8") + b"\n" + conn.sendall(payload) + + @staticmethod + def _read_line(conn: socket.socket) -> bytes: + chunks: list[bytes] = [] + while True: + chunk = conn.recv(65536) + if not chunk: + break + if b"\n" in chunk: + before, _, _ = chunk.partition(b"\n") + chunks.append(before) + break + chunks.append(chunk) + if sum(len(part) for part in chunks) > 1_000_000: + raise ValueError("request exceeds 1MB") + if not chunks: + raise ValueError("empty request") + return b"".join(chunks) + + def _handle_request(self, request: dict[str, Any]) -> dict[str, Any]: + if request.get("method") != "brain_search": + raise ValueError(f"unsupported method: {request.get('method')}") + arguments = request.get("arguments") or {} + if not isinstance(arguments, dict): + raise ValueError("arguments must be an object") + + text, structured = asyncio.run(self._search(arguments)) + metadata: dict[str, Any] = {} + if structured is not None: + metadata["structuredContent"] = structured + return {"ok": True, "text": text, "metadata": metadata} + + async def _search(self, arguments: dict[str, Any]) -> tuple[str, dict[str, Any] | None]: + from brainlayer.mcp.search_handler import _brain_search + + source = arguments.get("source") + if source is None or source == "": + source = "all" + + result = await _brain_search( + query=str(arguments.get("query") or ""), + project=arguments.get("project"), + source=source, + tag=arguments.get("tag"), + importance_min=arguments.get("importance_min"), + num_results=int(arguments.get("num_results") or 5), + detail=str(arguments.get("detail") or "compact"), + ) + + if isinstance(result, tuple): + content, structured = result + return self._content_text(content), structured if isinstance(structured, dict) else None + if hasattr(result, "content"): + return self._content_text(result.content), None + return self._content_text(result), None + + @staticmethod + def _content_text(content: Any) -> str: + if isinstance(content, list): + parts = [] + for item in content: + text = getattr(item, "text", None) + if text is None and isinstance(item, dict): + text = item.get("text") + if text is not None: + parts.append(str(text)) + return "\n".join(parts) + text = getattr(content, "text", None) + if text is not None: + return str(text) + return str(content) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="BrainBar persistent hybrid search helper") + parser.add_argument("--socket-path", required=True) + parser.add_argument("--db-path", required=True) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + helper = HybridSearchHelper(socket_path=Path(args.socket_path), db_path=Path(args.db_path)) + signal.signal(signal.SIGTERM, helper.stop) + signal.signal(signal.SIGINT, helper.stop) + helper.serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_brainbar_hybrid_helper.py b/tests/test_brainbar_hybrid_helper.py new file mode 100644 index 00000000..7aa9bde8 --- /dev/null +++ b/tests/test_brainbar_hybrid_helper.py @@ -0,0 +1,55 @@ +from pathlib import Path + +from mcp.types import TextContent + +from brainlayer.brainbar_hybrid_helper import HybridSearchHelper + + +def test_helper_routes_brain_search_to_python_mcp_with_source_all_default(monkeypatch, tmp_path): + calls = [] + + async def fake_brain_search(**kwargs): + calls.append(kwargs) + return ( + [TextContent(type="text", text="hybrid result manual-a0b8a")], + {"query": kwargs["query"], "results": [{"chunk_id": "manual-a0b8a"}]}, + ) + + monkeypatch.setattr("brainlayer.mcp.search_handler._brain_search", fake_brain_search) + + helper = HybridSearchHelper(socket_path=tmp_path / "helper.sock", db_path=Path("/tmp/test.db")) + response = helper._handle_request( + { + "method": "brain_search", + "arguments": { + "query": "techgym speakers workshop", + "num_results": 3, + "project": "brainlayer", + "tag": "speakers-workshop", + "importance_min": 8, + "detail": "compact", + }, + } + ) + + assert response == { + "ok": True, + "text": "hybrid result manual-a0b8a", + "metadata": { + "structuredContent": { + "query": "techgym speakers workshop", + "results": [{"chunk_id": "manual-a0b8a"}], + } + }, + } + assert calls == [ + { + "query": "techgym speakers workshop", + "project": "brainlayer", + "source": "all", + "tag": "speakers-workshop", + "importance_min": 8, + "num_results": 3, + "detail": "compact", + } + ] From 0564f88eec46811754f86ce89bab8f369140a6a6 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 09:03:17 +0300 Subject: [PATCH 13/28] fix: harden BrainBar hybrid helper startup --- .../Sources/BrainBar/BrainBarServer.swift | 11 +++- .../BrainBar/HybridSearchHelperClient.swift | 56 +++++++++++++++++-- brain-bar/Sources/BrainBar/MCPRouter.swift | 49 ++++++++++------ .../HybridSearchHelperClientTests.swift | 48 ++++++++++++++++ .../Tests/BrainBarTests/MCPRouterTests.swift | 51 ++++++++++++----- .../RecordingHybridSearchClient.swift | 33 +++++++++++ .../SocketIntegrationTests.swift | 16 +----- brain-bar/build-app.sh | 24 +++++++- src/brainlayer/brainbar_hybrid_helper.py | 23 ++++++-- tests/test_brainbar_hybrid_helper.py | 17 ++++++ 10 files changed, 269 insertions(+), 59 deletions(-) create mode 100644 brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift create mode 100644 brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift index cc0a77e4..296896da 100644 --- a/brain-bar/Sources/BrainBar/BrainBarServer.swift +++ b/brain-bar/Sources/BrainBar/BrainBarServer.swift @@ -204,15 +204,17 @@ final class BrainBarServer: @unchecked Sendable { return } + let ownedHybridClient: HybridSearchHelperClient? let hybridClient: HybridSearchClientProtocol? if let providedHybridSearchClient { + ownedHybridClient = nil hybridClient = providedHybridSearchClient } else if providedDatabase == nil && enableHybridSearchHelper { let client = HybridSearchHelperClient(dbPath: dbPath) - client.start() - hybridSearchHelperClient = client + ownedHybridClient = client hybridClient = client } else { + ownedHybridClient = nil hybridClient = nil } @@ -286,6 +288,11 @@ final class BrainBarServer: @unchecked Sendable { NSLog("[BrainBar] Server listening on %@", socketPath) debugLog("SERVER STARTED — listening on \(socketPath)") + if let ownedHybridClient { + hybridSearchHelperClient = ownedHybridClient + ownedHybridClient.start() + } + // 3. NOW open the database (may take time on cold start with 8 GB file). // Connections accepted above queue in the listen backlog. // initialize / tools/list already work; tools/call returns a diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index c19395b9..a63e176d 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -34,6 +34,10 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen self.environment = environment } + deinit { + stop() + } + static func defaultSocketPath() -> String { "/tmp/brainbar-hybrid-\(ProcessInfo.processInfo.processIdentifier).sock" } @@ -48,13 +52,54 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen return candidate } } - let repoCandidate = "/Users/etanheyman/Gits/brainlayer/.venv/bin/python" - if FileManager.default.isExecutableFile(atPath: repoCandidate) { - return repoCandidate + if let repoRoot = normalizedRepoRoot(environment: environment) { + let candidate = "\(repoRoot)/.venv/bin/python" + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + if let python3 = findExecutable(named: "python3", path: environment["PATH"]) { + return python3 + } + if let python = findExecutable(named: "python", path: environment["PATH"]) { + return python } return "/usr/bin/env" } + static func resolvePythonPath(environment: [String: String]) -> String? { + if let existing = environment["PYTHONPATH"], !existing.isEmpty { + return existing + } + guard let repoRoot = normalizedRepoRoot(environment: environment) else { + return nil + } + let sourcePath = "\(repoRoot)/src" + guard FileManager.default.fileExists(atPath: sourcePath) else { + return nil + } + return sourcePath + } + + private static func normalizedRepoRoot(environment: [String: String]) -> String? { + guard let raw = environment["BRAINLAYER_REPO_ROOT"], + !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return URL(fileURLWithPath: raw).standardizedFileURL.path + } + + private static func findExecutable(named name: String, path: String?) -> String? { + let searchPath = path?.split(separator: ":").map(String.init) ?? [] + for dir in searchPath where !dir.isEmpty { + let candidate = "\(dir)/\(name)" + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + func start() { queue.sync { startLocked() @@ -109,9 +154,10 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen var env = environment env["BRAINLAYER_DB"] = dbPath - if env["PYTHONPATH"] == nil || env["PYTHONPATH"]?.isEmpty == true { - env["PYTHONPATH"] = "/Users/etanheyman/Gits/brainlayer/src" + if let pythonPath = Self.resolvePythonPath(environment: env) { + env["PYTHONPATH"] = pythonPath } + env["PYTHONUNBUFFERED"] = "1" proc.environment = env proc.standardInput = Pipe() proc.standardOutput = Pipe() diff --git a/brain-bar/Sources/BrainBar/MCPRouter.swift b/brain-bar/Sources/BrainBar/MCPRouter.swift index 31399d7a..8cf1f09b 100644 --- a/brain-bar/Sources/BrainBar/MCPRouter.swift +++ b/brain-bar/Sources/BrainBar/MCPRouter.swift @@ -276,21 +276,7 @@ final class MCPRouter: @unchecked Sendable { } } - let textSection: String - let metadata: [String: Any] - if let hybridSearchClient, subscriberID == nil, !unreadOnly { - let response = try hybridSearchClient.search(arguments: hybridSearchArguments( - query: query, - limit: limit, - project: project, - source: source, - tag: tag, - importanceMin: importanceMin, - detail: args["detail"] as? String - )) - textSection = response.text - metadata = response.metadata - } else { + func searchViaBrainBarDatabase() throws -> (text: String, metadata: [String: Any]) { let results = try db.search( query: query, limit: limit, @@ -302,8 +288,37 @@ final class MCPRouter: @unchecked Sendable { unreadOnly: unreadOnly ) let typedResults = results.map(SearchResult.init(payload:)) - textSection = TextFormatter.formatSearchResults(query: query, results: typedResults, total: typedResults.count) - metadata = [:] + return ( + TextFormatter.formatSearchResults(query: query, results: typedResults, total: typedResults.count), + [:] + ) + } + + let textSection: String + let metadata: [String: Any] + if let hybridSearchClient, subscriberID == nil, !unreadOnly { + do { + let response = try hybridSearchClient.search(arguments: hybridSearchArguments( + query: query, + limit: limit, + project: project, + source: source, + tag: tag, + importanceMin: importanceMin, + detail: args["detail"] as? String + )) + textSection = response.text + metadata = response.metadata + } catch { + NSLog("[BrainBar] Hybrid search helper failed, falling back to BrainBar database search: %@", String(describing: error)) + let fallback = try searchViaBrainBarDatabase() + textSection = fallback.text + metadata = fallback.metadata + } + } else { + let fallback = try searchViaBrainBarDatabase() + textSection = fallback.text + metadata = fallback.metadata } // KG section goes before the envelope diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift new file mode 100644 index 00000000..5ad9ee0a --- /dev/null +++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift @@ -0,0 +1,48 @@ +import Darwin +import XCTest +@testable import BrainBar + +final class HybridSearchHelperClientTests: XCTestCase { + func testResolvePythonExecutableUsesInstallTimeRepoRoot() throws { + let repoRoot = NSTemporaryDirectory() + "brainbar-helper-repo-\(UUID().uuidString)" + let pythonPath = "\(repoRoot)/.venv/bin/python" + try FileManager.default.createDirectory( + atPath: "\(repoRoot)/.venv/bin", + withIntermediateDirectories: true + ) + FileManager.default.createFile(atPath: pythonPath, contents: Data("#!/bin/sh\n".utf8)) + chmod(pythonPath, 0o755) + defer { try? FileManager.default.removeItem(atPath: repoRoot) } + + let resolved = HybridSearchHelperClient.resolvePythonExecutable(environment: [ + "BRAINLAYER_REPO_ROOT": repoRoot, + "PATH": "/nonexistent" + ]) + + XCTAssertEqual(resolved, pythonPath) + } + + func testResolvePythonPathUsesRepoSourceDirectoryWhenUnset() throws { + let repoRoot = NSTemporaryDirectory() + "brainbar-helper-src-\(UUID().uuidString)" + try FileManager.default.createDirectory( + atPath: "\(repoRoot)/src", + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(atPath: repoRoot) } + + let resolved = HybridSearchHelperClient.resolvePythonPath(environment: [ + "BRAINLAYER_REPO_ROOT": repoRoot + ]) + + XCTAssertEqual(resolved, "\(repoRoot)/src") + } + + func testResolvePythonPathPreservesExistingPythonPath() throws { + let resolved = HybridSearchHelperClient.resolvePythonPath(environment: [ + "PYTHONPATH": "/custom/pythonpath", + "BRAINLAYER_REPO_ROOT": "/ignored" + ]) + + XCTAssertEqual(resolved, "/custom/pythonpath") + } +} diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index 66eb01d9..cb0b78b6 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -355,6 +355,43 @@ final class MCPRouterTests: XCTestCase { XCTAssertNotNil(result["structuredContent"]) } + func testBrainSearchFallsBackToBrainBarDatabaseWhenHybridHelperFails() throws { + let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-fallback-\(UUID().uuidString).db" + defer { try? FileManager.default.removeItem(atPath: tempDB) } + let db = BrainDatabase(path: tempDB) + defer { db.close() } + try db.insertChunk( + id: "fallback-fts-result", + content: "techgym speakers workshop fallback result from BrainBar database search", + sessionId: "s1", + project: "brainlayer", + contentType: "assistant_text", + importance: 5 + ) + + let helper = RecordingHybridSearchClient() + let router = MCPRouter(hybridSearchClient: helper) + router.setDatabase(db) + + let response = router.handle([ + "jsonrpc": "2.0", + "id": 177, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": ["query": "fallback", "num_results": 3] + ] as [String: Any] + ]) + + let result = try XCTUnwrap(response["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + let text = content.first?["text"] as? String ?? "" + + XCTAssertEqual(helper.requests.count, 1) + XCTAssertTrue(text.contains("fallback-fts"), text) + XCTAssertNil(result["structuredContent"]) + } + func testBrainSearchUnreadOnlyStaysOnBrainBarQueuePathWhenHybridHelperExists() throws { let tempDB = NSTemporaryDirectory() + "brainbar-unread-helper-\(UUID().uuidString).db" defer { try? FileManager.default.removeItem(atPath: tempDB) } @@ -1682,17 +1719,3 @@ private func openSQLiteConnection(path: String) throws -> OpaquePointer { } return db } - -private final class RecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { - private let response: HybridSearchResponse - private(set) var requests: [[String: Any]] = [] - - init(response: HybridSearchResponse) { - self.response = response - } - - func search(arguments: [String: Any]) throws -> HybridSearchResponse { - requests.append(arguments) - return response - } -} diff --git a/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift b/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift new file mode 100644 index 00000000..db2cf5aa --- /dev/null +++ b/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift @@ -0,0 +1,33 @@ +import Foundation +@testable import BrainBar + +enum RecordingHybridSearchClientError: LocalizedError { + case injectedFailure + + var errorDescription: String? { + switch self { + case .injectedFailure: + return "injected hybrid search failure" + } + } +} + +final class RecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { + private let result: Result + private(set) var requests: [[String: Any]] = [] + + init(response: HybridSearchResponse) { + result = .success(response) + } + + init(error: Error = RecordingHybridSearchClientError.injectedFailure) { + result = .failure(error) + } + + deinit {} + + func search(arguments: [String: Any]) throws -> HybridSearchResponse { + requests.append(arguments) + return try result.get() + } +} diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index affa99ad..596165fa 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -225,7 +225,7 @@ final class SocketIntegrationTests: XCTestCase { func testMCPBrainSearchOverSocketUsesInjectedHybridHelper() throws { server.stop() - let helper = SocketRecordingHybridSearchClient( + let helper = RecordingHybridSearchClient( response: HybridSearchResponse( text: #""" ┌─ brain_search: "techgym speakers workshop" ─ 1 result @@ -1034,17 +1034,3 @@ final class SocketIntegrationTests: XCTestCase { throw NSError(domain: "test", code: 5, userInfo: [NSLocalizedDescriptionKey: "Timeout reading line JSON"]) } } - -private final class SocketRecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { - private let response: HybridSearchResponse - private(set) var requests: [[String: Any]] = [] - - init(response: HybridSearchResponse) { - self.response = response - } - - func search(arguments: [String: Any]) throws -> HybridSearchResponse { - requests.append(arguments) - return response - } -} diff --git a/brain-bar/build-app.sh b/brain-bar/build-app.sh index cf5e0a9d..d1631332 100755 --- a/brain-bar/build-app.sh +++ b/brain-bar/build-app.sh @@ -142,6 +142,22 @@ plist_set_string() { fi } +configure_launchagent_environment() { + local plist_path="$1" + local repo_root="$2" + local python_path="$repo_root/.venv/bin/python" + + "$PLIST_BUDDY" -c "Delete :EnvironmentVariables" "$plist_path" >/dev/null 2>&1 || true + "$PLIST_BUDDY" -c "Add :EnvironmentVariables dict" "$plist_path" + "$PLIST_BUDDY" -c "Add :EnvironmentVariables:BRAINLAYER_REPO_ROOT string \"$repo_root\"" "$plist_path" + if [ -d "$repo_root/src" ]; then + "$PLIST_BUDDY" -c "Add :EnvironmentVariables:PYTHONPATH string \"$repo_root/src\"" "$plist_path" + fi + if [ -x "$python_path" ]; then + "$PLIST_BUDDY" -c "Add :EnvironmentVariables:BRAINBAR_PYTHON string \"$python_path\"" "$plist_path" + fi +} + stamp_info_plist() { local plist_path="$1" local commit_sha="$2" @@ -172,7 +188,8 @@ wait_for_brainbar_exit() { wait_for_socket() { local path="$1" - for _ in $(seq 1 100); do + local attempts="${BRAINBAR_SOCKET_WAIT_ATTEMPTS:-300}" + for _ in $(seq 1 "$attempts"); do if [ -S "$path" ]; then return 0 fi @@ -253,7 +270,10 @@ fi if [ "$DEV_BUNDLE_BUILD" -eq 0 ] && [ -f "$PLIST_SRC" ]; then echo "[build-app] Installing LaunchAgent to $PLIST_DST..." bootout_launchagent - sed "s|/Applications/BrainBar.app|$APP_DIR|g" "$PLIST_SRC" > "$PLIST_DST" + TMP_PLIST="$(mktemp)" + sed "s|/Applications/BrainBar.app|$APP_DIR|g" "$PLIST_SRC" > "$TMP_PLIST" + configure_launchagent_environment "$TMP_PLIST" "$CURRENT_REPO_ROOT" + mv "$TMP_PLIST" "$PLIST_DST" launchctl bootstrap "$LAUNCH_DOMAIN" "$PLIST_DST" launchctl kickstart -k "$LAUNCH_DOMAIN/$PLIST_LABEL" echo "[build-app] LaunchAgent installed — BrainBar will auto-restart after quit" diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py index 04aea9e2..a9a90d91 100644 --- a/src/brainlayer/brainbar_hybrid_helper.py +++ b/src/brainlayer/brainbar_hybrid_helper.py @@ -66,6 +66,7 @@ def serve_forever(self) -> None: break raise with conn: + conn.settimeout(0.25) self._handle_connection(conn) finally: server.close() @@ -91,17 +92,22 @@ def _handle_connection(self, conn: socket.socket) -> None: @staticmethod def _read_line(conn: socket.socket) -> bytes: chunks: list[bytes] = [] + total = 0 while True: chunk = conn.recv(65536) if not chunk: break if b"\n" in chunk: before, _, _ = chunk.partition(b"\n") + total += len(before) + if total > 1_000_000: + raise ValueError("request exceeds 1MB") chunks.append(before) break - chunks.append(chunk) - if sum(len(part) for part in chunks) > 1_000_000: + total += len(chunk) + if total > 1_000_000: raise ValueError("request exceeds 1MB") + chunks.append(chunk) if not chunks: raise ValueError("empty request") return b"".join(chunks) @@ -157,19 +163,28 @@ def _content_text(content: Any) -> str: text = getattr(content, "text", None) if text is not None: return str(text) + if isinstance(content, dict): + text = content.get("text") + if text is not None: + return str(text) return str(content) def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="BrainBar persistent hybrid search helper") parser.add_argument("--socket-path", required=True) - parser.add_argument("--db-path", required=True) + parser.add_argument("--db-path") return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) - helper = HybridSearchHelper(socket_path=Path(args.socket_path), db_path=Path(args.db_path)) + if args.db_path: + os.environ["BRAINLAYER_DB"] = args.db_path + + from brainlayer.paths import get_db_path + + helper = HybridSearchHelper(socket_path=Path(args.socket_path), db_path=get_db_path()) signal.signal(signal.SIGTERM, helper.stop) signal.signal(signal.SIGINT, helper.stop) helper.serve_forever() diff --git a/tests/test_brainbar_hybrid_helper.py b/tests/test_brainbar_hybrid_helper.py index 7aa9bde8..205da9ad 100644 --- a/tests/test_brainbar_hybrid_helper.py +++ b/tests/test_brainbar_hybrid_helper.py @@ -1,6 +1,7 @@ from pathlib import Path from mcp.types import TextContent +import pytest from brainlayer.brainbar_hybrid_helper import HybridSearchHelper @@ -53,3 +54,19 @@ async def fake_brain_search(**kwargs): "detail": "compact", } ] + + +def test_content_text_extracts_single_dict_text(): + assert HybridSearchHelper._content_text({"type": "text", "text": "dict text"}) == "dict text" + + +def test_read_line_rejects_oversized_chunk_before_newline(): + class FakeSocket: + def __init__(self): + self.chunks = [b"x" * 1_000_001 + b"\n"] + + def recv(self, _size): + return self.chunks.pop(0) if self.chunks else b"" + + with pytest.raises(ValueError, match="request exceeds 1MB"): + HybridSearchHelper._read_line(FakeSocket()) From 34f5bc6431487ff568eee5b66eb12f832028619e Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 10:06:26 +0300 Subject: [PATCH 14/28] fix: address BrainBar hybrid review followups --- .../BrainBar/HybridSearchHelperClient.swift | 9 +++++- .../HybridSearchHelperClientTests.swift | 2 ++ .../RecordingHybridSearchClient.swift | 13 +++++++-- brain-bar/build-app.sh | 2 ++ src/brainlayer/brainbar_hybrid_helper.py | 10 +++++-- src/brainlayer/mcp/search_handler.py | 3 +- src/brainlayer/pipeline/entity_extraction.py | 9 +++++- tests/conftest.py | 10 +++++++ tests/test_brainbar_hybrid_helper.py | 18 +++++++++--- tests/test_digest_pipeline_v2.py | 6 ++-- tests/test_search_exact_chunk_id.py | 29 +++++++++++++++++++ 11 files changed, 98 insertions(+), 13 deletions(-) diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index a63e176d..d92130a5 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -15,6 +15,7 @@ protocol HybridSearchClientProtocol: AnyObject, Sendable { } final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sendable { + private static let maxResponseBytes = 10 * 1024 * 1024 private let socketPath: String private let dbPath: String private let pythonExecutable: String @@ -160,7 +161,7 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen env["PYTHONUNBUFFERED"] = "1" proc.environment = env proc.standardInput = Pipe() - proc.standardOutput = Pipe() + proc.standardOutput = FileHandle.nullDevice proc.standardError = FileHandle.standardError do { @@ -273,6 +274,9 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen break } result.append(byte) + if result.count > Self.maxResponseBytes { + throw HybridSearchHelperError.responseTooLarge(Self.maxResponseBytes) + } } guard !result.isEmpty else { throw HybridSearchHelperError.invalidResponse @@ -287,6 +291,7 @@ enum HybridSearchHelperError: LocalizedError { case connect(Int32) case write(Int32) case read(Int32) + case responseTooLarge(Int) case invalidResponse case helperError(String) @@ -302,6 +307,8 @@ enum HybridSearchHelperError: LocalizedError { return "hybrid helper write failed: errno \(code)" case .read(let code): return "hybrid helper read failed: errno \(code)" + case .responseTooLarge(let limit): + return "hybrid helper response exceeded \(limit) bytes" case .invalidResponse: return "hybrid helper returned an invalid response" case .helperError(let message): diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift index 5ad9ee0a..34b6caae 100644 --- a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift +++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift @@ -3,6 +3,8 @@ import XCTest @testable import BrainBar final class HybridSearchHelperClientTests: XCTestCase { + deinit {} + func testResolvePythonExecutableUsesInstallTimeRepoRoot() throws { let repoRoot = NSTemporaryDirectory() + "brainbar-helper-repo-\(UUID().uuidString)" let pythonPath = "\(repoRoot)/.venv/bin/python" diff --git a/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift b/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift index db2cf5aa..bbb7d754 100644 --- a/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift +++ b/brain-bar/Tests/BrainBarTests/RecordingHybridSearchClient.swift @@ -14,7 +14,14 @@ enum RecordingHybridSearchClientError: LocalizedError { final class RecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked Sendable { private let result: Result - private(set) var requests: [[String: Any]] = [] + private let lock = NSLock() + private var recordedRequests: [[String: Any]] = [] + + var requests: [[String: Any]] { + lock.lock() + defer { lock.unlock() } + return recordedRequests + } init(response: HybridSearchResponse) { result = .success(response) @@ -27,7 +34,9 @@ final class RecordingHybridSearchClient: HybridSearchClientProtocol, @unchecked deinit {} func search(arguments: [String: Any]) throws -> HybridSearchResponse { - requests.append(arguments) + lock.lock() + recordedRequests.append(arguments) + lock.unlock() return try result.get() } } diff --git a/brain-bar/build-app.sh b/brain-bar/build-app.sh index d1631332..7c17a007 100755 --- a/brain-bar/build-app.sh +++ b/brain-bar/build-app.sh @@ -271,9 +271,11 @@ if [ "$DEV_BUNDLE_BUILD" -eq 0 ] && [ -f "$PLIST_SRC" ]; then echo "[build-app] Installing LaunchAgent to $PLIST_DST..." bootout_launchagent TMP_PLIST="$(mktemp)" + trap 'rm -f "$TMP_PLIST"' EXIT sed "s|/Applications/BrainBar.app|$APP_DIR|g" "$PLIST_SRC" > "$TMP_PLIST" configure_launchagent_environment "$TMP_PLIST" "$CURRENT_REPO_ROOT" mv "$TMP_PLIST" "$PLIST_DST" + trap - EXIT launchctl bootstrap "$LAUNCH_DOMAIN" "$PLIST_DST" launchctl kickstart -k "$LAUNCH_DOMAIN/$PLIST_LABEL" echo "[build-app] LaunchAgent installed — BrainBar will auto-restart after quit" diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py index a9a90d91..172e3c72 100644 --- a/src/brainlayer/brainbar_hybrid_helper.py +++ b/src/brainlayer/brainbar_hybrid_helper.py @@ -67,7 +67,10 @@ def serve_forever(self) -> None: raise with conn: conn.settimeout(0.25) - self._handle_connection(conn) + try: + self._handle_connection(conn) + except OSError: + continue finally: server.close() try: @@ -87,7 +90,10 @@ def _handle_connection(self, conn: socket.socket) -> None: response = {"ok": False, "error": str(exc)} payload = json.dumps(_json_safe(response), separators=(",", ":")).encode("utf-8") + b"\n" - conn.sendall(payload) + try: + conn.sendall(payload) + except OSError: + return @staticmethod def _read_line(conn: socket.socket) -> bytes: diff --git a/src/brainlayer/mcp/search_handler.py b/src/brainlayer/mcp/search_handler.py index 24611bba..0bfe82e9 100644 --- a/src/brainlayer/mcp/search_handler.py +++ b/src/brainlayer/mcp/search_handler.py @@ -210,7 +210,8 @@ def _exact_chunk_lookup_result( or is_precompact_checkpoint_content(chunk.get("content")) ): return _empty_exact_chunk_lookup_result(query) - if any(value is not None for value in (source, intent, sentiment, source_filter, correction_category)): + effective_source = None if source == "all" else source + if any(value is not None for value in (effective_source, intent, sentiment, source_filter, correction_category)): return None if project is not None: chunk_project = _normalize_project_name(chunk.get("project")) or chunk.get("project") diff --git a/src/brainlayer/pipeline/entity_extraction.py b/src/brainlayer/pipeline/entity_extraction.py index 01b90442..f97ad3c2 100644 --- a/src/brainlayer/pipeline/entity_extraction.py +++ b/src/brainlayer/pipeline/entity_extraction.py @@ -11,12 +11,19 @@ import json import logging +import os import re from dataclasses import dataclass, field from typing import Any, Optional logger = logging.getLogger(__name__) +_FALSEY = {"0", "false", "no"} + + +def _llm_extraction_enabled() -> bool: + return os.environ.get("BRAINLAYER_LLM_ENTITY_EXTRACTION", "1").lower() not in _FALSEY + @dataclass class ExtractedEntity: @@ -312,7 +319,7 @@ def extract_entities_llm( Returns: Tuple of (entities, relations). """ - if not text.strip(): + if not text.strip() or (llm_caller is None and not _llm_extraction_enabled()): return [], [] if llm_caller is None: diff --git a/tests/conftest.py b/tests/conftest.py index 436941c4..7cc43c0d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,16 @@ def eval_project() -> str: return f"eval-{uuid.uuid4().hex[:8]}" +@pytest.fixture(autouse=True) +def disable_live_gemini_for_unit_tests(monkeypatch, request): + """Keep unit tests from making live Gemini calls through local shell env.""" + if request.node.get_closest_marker("live"): + return + + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_GENERATIVE_AI_API_KEY", raising=False) + + @pytest.fixture def test_user() -> str: """Username for path-based tests. diff --git a/tests/test_brainbar_hybrid_helper.py b/tests/test_brainbar_hybrid_helper.py index 205da9ad..80960284 100644 --- a/tests/test_brainbar_hybrid_helper.py +++ b/tests/test_brainbar_hybrid_helper.py @@ -1,7 +1,5 @@ -from pathlib import Path - -from mcp.types import TextContent import pytest +from mcp.types import TextContent from brainlayer.brainbar_hybrid_helper import HybridSearchHelper @@ -18,7 +16,7 @@ async def fake_brain_search(**kwargs): monkeypatch.setattr("brainlayer.mcp.search_handler._brain_search", fake_brain_search) - helper = HybridSearchHelper(socket_path=tmp_path / "helper.sock", db_path=Path("/tmp/test.db")) + helper = HybridSearchHelper(socket_path=tmp_path / "helper.sock", db_path=tmp_path / "test.db") response = helper._handle_request( { "method": "brain_search", @@ -70,3 +68,15 @@ def recv(self, _size): with pytest.raises(ValueError, match="request exceeds 1MB"): HybridSearchHelper._read_line(FakeSocket()) + + +def test_handle_connection_ignores_client_disconnect_on_response(monkeypatch, tmp_path): + helper = HybridSearchHelper(socket_path=tmp_path / "helper.sock", db_path=tmp_path / "test.db") + monkeypatch.setattr(helper, "_read_line", lambda _conn: b'{"method":"brain_search","arguments":{"query":"x"}}') + monkeypatch.setattr(helper, "_handle_request", lambda _request: {"ok": True, "text": "result", "metadata": {}}) + + class ClosedSocket: + def sendall(self, _payload): + raise BrokenPipeError("client disconnected") + + helper._handle_connection(ClosedSocket()) diff --git a/tests/test_digest_pipeline_v2.py b/tests/test_digest_pipeline_v2.py index 730492a9..a74b8e2b 100644 --- a/tests/test_digest_pipeline_v2.py +++ b/tests/test_digest_pipeline_v2.py @@ -719,10 +719,11 @@ def test_digest_then_digest_finds_duplicate(self, tmp_store): class TestPerformance: """Tests that the digest pipeline meets performance targets.""" - def test_digest_under_2_seconds(self, tmp_store): + def test_digest_under_2_seconds(self, tmp_store, monkeypatch): """Digest of 1000-word content completes in <2 seconds.""" from brainlayer.pipeline.digest import digest_content + monkeypatch.setenv("BRAINLAYER_LLM_ENTITY_EXTRACTION", "0") content = " ".join(["word"] * 1000) mock_embed = MagicMock(return_value=[0.05] * 1024) @@ -737,10 +738,11 @@ def test_digest_under_2_seconds(self, tmp_store): assert elapsed < 2.0, f"Digest took {elapsed:.2f}s, expected <2s" - def test_connect_under_2_seconds(self, tmp_store): + def test_connect_under_2_seconds(self, tmp_store, monkeypatch): """Connect mode for 1000-word content completes in <2 seconds.""" from brainlayer.pipeline.digest import digest_connect + monkeypatch.setenv("BRAINLAYER_LLM_ENTITY_EXTRACTION", "0") content = " ".join(["word"] * 1000) mock_embed = MagicMock(return_value=[0.05] * 1024) diff --git a/tests/test_search_exact_chunk_id.py b/tests/test_search_exact_chunk_id.py index 7c855acb..aff40a36 100644 --- a/tests/test_search_exact_chunk_id.py +++ b/tests/test_search_exact_chunk_id.py @@ -68,6 +68,35 @@ async def test_brain_search_exact_chunk_id_defaults_missing_project_to_unknown() assert structured["results"][0]["project"] == "unknown" +@pytest.mark.asyncio +async def test_brain_search_exact_chunk_id_treats_source_all_as_unfiltered(): + """BrainBar forwards source='all'; exact chunk-id lookup must still short-circuit.""" + chunk_id = "brainbar-sourceall01" + mock_store = MagicMock() + mock_store.get_chunk.return_value = { + "id": chunk_id, + "content": "Exact chunk content reachable when source all is unfiltered", + "source_file": "docs/repro.md", + "project": "brainlayer", + "content_type": "note", + "importance": 8, + "created_at": "2026-05-18T09:15:00Z", + "summary": "Source all exact lookup repro", + } + + with ( + patch("brainlayer.mcp.search_handler._get_vector_store", return_value=mock_store), + patch( + "brainlayer.mcp.search_handler._search", + new=AsyncMock(side_effect=AssertionError("source='all' exact chunk-id query should bypass hybrid search")), + ), + ): + _, structured = await _brain_search(query=chunk_id, source="all", detail="compact") + + assert structured["total"] == 1 + assert structured["results"][0]["chunk_id"] == chunk_id + + @pytest.mark.asyncio async def test_brain_search_exact_checkpoint_chunk_id_returns_empty_without_fallback(): """Default exact chunk-id lookup must not leak checkpoint chunks via fallback search.""" From 1f1e3e5af30777942d253895726400830f298a16 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 10:37:47 +0300 Subject: [PATCH 15/28] fix: report hybrid helper launch failures --- .../BrainBar/HybridSearchHelperClient.swift | 14 +++++++++++--- .../HybridSearchHelperClientTests.swift | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index d92130a5..019fda56 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -103,7 +103,11 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen func start() { queue.sync { - startLocked() + do { + try startLocked() + } catch { + NSLog("[BrainBar] Hybrid search helper startup deferred after failure: %@", String(describing: error)) + } } } @@ -117,12 +121,12 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen func search(arguments: [String: Any]) throws -> HybridSearchResponse { try queue.sync { - startLocked() + try startLocked() return try send(arguments: arguments) } } - private func startLocked() { + private func startLocked() throws { if let process, process.isRunning { return } @@ -171,6 +175,7 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen } catch { NSLog("[BrainBar] Failed to start hybrid search helper: %@", String(describing: error)) process = nil + throw HybridSearchHelperError.launch(String(describing: error)) } } @@ -291,6 +296,7 @@ enum HybridSearchHelperError: LocalizedError { case connect(Int32) case write(Int32) case read(Int32) + case launch(String) case responseTooLarge(Int) case invalidResponse case helperError(String) @@ -307,6 +313,8 @@ enum HybridSearchHelperError: LocalizedError { return "hybrid helper write failed: errno \(code)" case .read(let code): return "hybrid helper read failed: errno \(code)" + case .launch(let message): + return "hybrid helper launch failed: \(message)" case .responseTooLarge(let limit): return "hybrid helper response exceeded \(limit) bytes" case .invalidResponse: diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift index 34b6caae..72206a5a 100644 --- a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift +++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift @@ -47,4 +47,22 @@ final class HybridSearchHelperClientTests: XCTestCase { XCTAssertEqual(resolved, "/custom/pythonpath") } + + func testSearchReportsLaunchFailureWithoutSocketRetry() throws { + let client = HybridSearchHelperClient( + socketPath: NSTemporaryDirectory() + "brainbar-missing-helper-\(UUID().uuidString).sock", + dbPath: "/tmp/brainlayer-test.db", + pythonExecutable: "/no/such/python", + environment: [:] + ) + + do { + _ = try client.search(arguments: ["query": "techgym speakers workshop"]) + XCTFail("Expected launch failure") + } catch let error as HybridSearchHelperError { + guard case .launch = error else { + return XCTFail("Expected launch error, got \(error)") + } + } + } } From 6c66db2439f586dd250dba8887e61b74dd7d6da2 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 10:48:10 +0300 Subject: [PATCH 16/28] fix: keep source all unfiltered for entity routing --- src/brainlayer/mcp/search_handler.py | 5 ++-- tests/test_search_filter_params.py | 44 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/brainlayer/mcp/search_handler.py b/src/brainlayer/mcp/search_handler.py index 0bfe82e9..6427ba90 100644 --- a/src/brainlayer/mcp/search_handler.py +++ b/src/brainlayer/mcp/search_handler.py @@ -618,6 +618,7 @@ async def _brain_search( return await _recall(topic=query, project=project, max_results=max_results, include_audit=include_audit) store = _get_vector_store() + effective_source = None if source == "all" else source exact_chunk_hit = _exact_chunk_lookup_result( query, store, @@ -628,7 +629,7 @@ async def _brain_search( importance_min=importance_min, date_from=date_from, date_to=date_to, - source=source, + source=effective_source, intent=intent, sentiment=sentiment, source_filter=source_filter, @@ -647,7 +648,7 @@ async def _brain_search( has_active_filters = any( [ content_type, - source, + effective_source, tag, intent, importance_min, diff --git a/tests/test_search_filter_params.py b/tests/test_search_filter_params.py index 463535d9..8c91079a 100644 --- a/tests/test_search_filter_params.py +++ b/tests/test_search_filter_params.py @@ -271,6 +271,50 @@ def test_include_checkpoints_does_not_skip_entity_routing(self): assert store.kg_hybrid_search.call_args[1]["include_checkpoints"] is True mock_search.assert_not_awaited() + def test_source_all_does_not_skip_entity_routing(self): + """source='all' means unfiltered global search, not an active source filter.""" + store = MagicMock(count=MagicMock(return_value=100)) + store.kg_hybrid_search.return_value = { + "chunks": { + "ids": [[]], + "documents": [[]], + "metadatas": [[]], + "distances": [[]], + } + } + fact_items = [{"source": "Etan", "relation": "works_on", "target": "BrainLayer"}] + with ( + patch("brainlayer.mcp.search_handler._get_vector_store", return_value=store), + patch("brainlayer.mcp.search_handler._expanded_fts_query", return_value=None), + patch( + "brainlayer.mcp.search_handler._get_embedding_model", + return_value=MagicMock(embed_query=MagicMock(return_value=[0.1] * 1024)), + ), + patch( + "brainlayer.mcp.search_handler._detect_entities", + return_value=[{"id": "e1", "name": "Etan", "entity_type": "person"}], + ) as mock_detect, + patch("brainlayer.mcp.search_handler._kg_facts_sql", return_value=fact_items), + patch( + "brainlayer.mcp.search_handler._search", + new_callable=AsyncMock, + return_value=MagicMock(), + ) as mock_search, + patch("brainlayer.mcp.search_handler._normalize_project_name", return_value=None), + ): + _content, structured = asyncio.run( + _brain_search( + query="Etan BrainLayer context", + source="all", + ) + ) + + mock_detect.assert_called_once_with("Etan BrainLayer context", store) + store.kg_hybrid_search.assert_called_once() + mock_search.assert_not_awaited() + assert structured["entity"] == "Etan" + assert structured["facts"] == fact_items + # ── Input schema validation ────────────────────────────────────────────────── From 5768b4bcd734dce522788bca673d244a49835672 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 10:56:53 +0300 Subject: [PATCH 17/28] fix: bound hybrid helper socket waits --- .../BrainBar/HybridSearchHelperClient.swift | 56 +++++++++++++++++-- .../HybridSearchHelperClientTests.swift | 20 +++++++ src/brainlayer/brainbar_hybrid_helper.py | 7 ++- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index 019fda56..311cf503 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -16,10 +16,12 @@ protocol HybridSearchClientProtocol: AnyObject, Sendable { final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sendable { private static let maxResponseBytes = 10 * 1024 * 1024 + private static let defaultSocketIOTimeout: TimeInterval = 60 private let socketPath: String private let dbPath: String private let pythonExecutable: String private let environment: [String: String] + private let socketIOTimeout: TimeInterval private let queue = DispatchQueue(label: "com.brainlayer.brainbar.hybrid-helper") private var process: Process? @@ -27,12 +29,14 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen socketPath: String? = nil, dbPath: String, pythonExecutable: String? = nil, - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + socketIOTimeout: TimeInterval = defaultSocketIOTimeout ) { self.socketPath = socketPath ?? Self.defaultSocketPath() self.dbPath = dbPath self.pythonExecutable = pythonExecutable ?? Self.resolvePythonExecutable(environment: environment) self.environment = environment + self.socketIOTimeout = socketIOTimeout } deinit { @@ -113,19 +117,30 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen func stop() { queue.sync { - process?.terminate() - process = nil - unlink(socketPath) + stopLocked() } } func search(arguments: [String: Any]) throws -> HybridSearchResponse { try queue.sync { try startLocked() - return try send(arguments: arguments) + do { + return try send(arguments: arguments) + } catch { + if Self.shouldRestartHelper(after: error) { + stopLocked() + } + throw error + } } } + private func stopLocked() { + process?.terminate() + process = nil + unlink(socketPath) + } + private func startLocked() throws { if let process, process.isRunning { return @@ -236,6 +251,7 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen } } if rc == 0 { + try Self.configureSocketTimeouts(fd: fd, timeout: socketIOTimeout) return fd } lastErrno = errno @@ -245,6 +261,33 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen throw HybridSearchHelperError.connect(lastErrno) } + static func configureSocketTimeouts(fd: Int32, timeout: TimeInterval) throws { + let boundedTimeout = max(timeout, 0.001) + let seconds = Int(boundedTimeout) + let microseconds = Int((boundedTimeout - Double(seconds)) * 1_000_000) + var timeval = timeval(tv_sec: seconds, tv_usec: Int32(microseconds)) + let size = socklen_t(MemoryLayout.size) + + if setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeval, size) != 0 { + throw HybridSearchHelperError.configureSocket(errno) + } + if setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeval, size) != 0 { + throw HybridSearchHelperError.configureSocket(errno) + } + } + + private static func shouldRestartHelper(after error: Error) -> Bool { + guard let helperError = error as? HybridSearchHelperError else { + return false + } + switch helperError { + case .connect, .configureSocket, .write, .read, .responseTooLarge, .invalidResponse: + return true + case .socket, .socketPathTooLong, .launch, .helperError: + return false + } + } + private func writeAll(fd: Int32, data: Data) throws { try data.withUnsafeBytes { rawBuffer in guard let base = rawBuffer.baseAddress else { return } @@ -294,6 +337,7 @@ enum HybridSearchHelperError: LocalizedError { case socket(Int32) case socketPathTooLong(String) case connect(Int32) + case configureSocket(Int32) case write(Int32) case read(Int32) case launch(String) @@ -309,6 +353,8 @@ enum HybridSearchHelperError: LocalizedError { return "hybrid helper socket path too long: \(path)" case .connect(let code): return "hybrid helper connect failed: errno \(code)" + case .configureSocket(let code): + return "hybrid helper socket timeout configuration failed: errno \(code)" case .write(let code): return "hybrid helper write failed: errno \(code)" case .read(let code): diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift index 72206a5a..ef95c4a3 100644 --- a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift +++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift @@ -65,4 +65,24 @@ final class HybridSearchHelperClientTests: XCTestCase { } } } + + func testConfigureSocketTimeoutsMakesReadsFinite() throws { + var fds: [Int32] = [0, 0] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) + defer { + close(fds[0]) + close(fds[1]) + } + + try HybridSearchHelperClient.configureSocketTimeouts(fd: fds[0], timeout: 0.05) + + var byte = UInt8(0) + let startedAt = Date() + let count = read(fds[0], &byte, 1) + let elapsed = Date().timeIntervalSince(startedAt) + + XCTAssertEqual(count, -1) + XCTAssertTrue(errno == EAGAIN || errno == EWOULDBLOCK) + XCTAssertLessThan(elapsed, 1.0) + } } diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py index 172e3c72..998af484 100644 --- a/src/brainlayer/brainbar_hybrid_helper.py +++ b/src/brainlayer/brainbar_hybrid_helper.py @@ -17,6 +17,9 @@ from pathlib import Path from typing import Any +_ACCEPT_TIMEOUT_SECONDS = 0.25 +_CONNECTION_TIMEOUT_SECONDS = 5.0 + def _json_safe(value: Any) -> Any: if value is None or isinstance(value, (str, int, float, bool)): @@ -53,7 +56,7 @@ def serve_forever(self) -> None: server.bind(os.fspath(self.socket_path)) os.chmod(self.socket_path, 0o600) server.listen(16) - server.settimeout(0.25) + server.settimeout(_ACCEPT_TIMEOUT_SECONDS) self.warm() while not self._stopped: @@ -66,7 +69,7 @@ def serve_forever(self) -> None: break raise with conn: - conn.settimeout(0.25) + conn.settimeout(_CONNECTION_TIMEOUT_SECONDS) try: self._handle_connection(conn) except OSError: From ecdafd5086bbc0e4a390bf543c61001ab47c1a9c Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 11:11:06 +0300 Subject: [PATCH 18/28] fix: harden hybrid helper fallback cleanup --- .../BrainBar/HybridSearchHelperClient.swift | 9 +- brain-bar/Sources/BrainBar/MCPRouter.swift | 25 ++++-- .../Tests/BrainBarTests/MCPRouterTests.swift | 83 +++++++++++++++++++ src/brainlayer/brainbar_hybrid_helper.py | 1 - 4 files changed, 106 insertions(+), 12 deletions(-) diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index 311cf503..98c213b2 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -251,8 +251,13 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen } } if rc == 0 { - try Self.configureSocketTimeouts(fd: fd, timeout: socketIOTimeout) - return fd + do { + try Self.configureSocketTimeouts(fd: fd, timeout: socketIOTimeout) + return fd + } catch { + close(fd) + throw error + } } lastErrno = errno close(fd) diff --git a/brain-bar/Sources/BrainBar/MCPRouter.swift b/brain-bar/Sources/BrainBar/MCPRouter.swift index 8cf1f09b..f174d344 100644 --- a/brain-bar/Sources/BrainBar/MCPRouter.swift +++ b/brain-bar/Sources/BrainBar/MCPRouter.swift @@ -263,17 +263,20 @@ final class MCPRouter: @unchecked Sendable { throw ToolError.noDatabase } - // Entity detection → KG fact lookup - var kgSection = "" - let hasActiveFilters = project != nil || sourceCountsAsFilter || tag != nil || subscriberID != nil || importanceMin != nil - if !hasActiveFilters { + func localKGSection() -> String { + let hasActiveFilters = project != nil || sourceCountsAsFilter || tag != nil || subscriberID != nil || importanceMin != nil + if hasActiveFilters { + return "" + } let detected = entityCache.detectEntities(in: query) - if let first = detected.first { - let facts = (try? db.lookupEntityFacts(entityName: first.name)) ?? [] - if !facts.isEmpty { - kgSection = TextFormatter.formatKGFacts(entity: first.name, facts: facts) - } + guard let first = detected.first else { + return "" + } + let facts = (try? db.lookupEntityFacts(entityName: first.name)) ?? [] + if facts.isEmpty { + return "" } + return TextFormatter.formatKGFacts(entity: first.name, facts: facts) } func searchViaBrainBarDatabase() throws -> (text: String, metadata: [String: Any]) { @@ -296,6 +299,7 @@ final class MCPRouter: @unchecked Sendable { let textSection: String let metadata: [String: Any] + let kgSection: String if let hybridSearchClient, subscriberID == nil, !unreadOnly { do { let response = try hybridSearchClient.search(arguments: hybridSearchArguments( @@ -309,16 +313,19 @@ final class MCPRouter: @unchecked Sendable { )) textSection = response.text metadata = response.metadata + kgSection = "" } catch { NSLog("[BrainBar] Hybrid search helper failed, falling back to BrainBar database search: %@", String(describing: error)) let fallback = try searchViaBrainBarDatabase() textSection = fallback.text metadata = fallback.metadata + kgSection = localKGSection() } } else { let fallback = try searchViaBrainBarDatabase() textSection = fallback.text metadata = fallback.metadata + kgSection = localKGSection() } // KG section goes before the envelope diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index cb0b78b6..ec5f48c7 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -355,6 +355,48 @@ final class MCPRouterTests: XCTestCase { XCTAssertNotNil(result["structuredContent"]) } + func testBrainSearchHybridSuccessDoesNotPrependSwiftKGFacts() throws { + let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-no-duplicate-kg-\(UUID().uuidString).db" + defer { try? FileManager.default.removeItem(atPath: tempDB) } + let db = BrainDatabase(path: tempDB) + defer { db.close() } + try db.insertEntity(id: "entity-etan", type: "person", name: "Etan") + try db.insertEntity(id: "entity-brainlayer", type: "project", name: "BrainLayer") + try db.insertRelation(sourceId: "entity-etan", targetId: "entity-brainlayer", relationType: "works_on") + try db.insertChunk( + id: "swift-fts-result", + content: "Etan BrainLayer local fallback result", + sessionId: "s1", + project: "brainlayer", + contentType: "assistant_text", + importance: 5 + ) + + let helperText = "python helper canonical KG/search output" + let helper = RecordingHybridSearchClient(response: HybridSearchResponse(text: helperText)) + let router = MCPRouter(hybridSearchClient: helper) + router.setDatabase(db) + + let response = router.handle([ + "jsonrpc": "2.0", + "id": 171, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": ["query": "Etan BrainLayer", "num_results": 3] + ] as [String: Any] + ]) + + let result = try XCTUnwrap(response["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + let text = content.first?["text"] as? String ?? "" + + XCTAssertEqual(text, helperText) + XCTAssertEqual(helper.requests.count, 1) + XCTAssertFalse(text.contains("### ◆ Etan"), "Swift KG facts must not duplicate Python helper entity output.") + XCTAssertFalse(text.contains("WORKS_ON"), "Swift KG facts must not be prepended on hybrid helper success.") + } + func testBrainSearchFallsBackToBrainBarDatabaseWhenHybridHelperFails() throws { let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-fallback-\(UUID().uuidString).db" defer { try? FileManager.default.removeItem(atPath: tempDB) } @@ -392,6 +434,47 @@ final class MCPRouterTests: XCTestCase { XCTAssertNil(result["structuredContent"]) } + func testBrainSearchFallbackStillPrependsSwiftKGFacts() throws { + let tempDB = NSTemporaryDirectory() + "brainbar-hybrid-fallback-kg-\(UUID().uuidString).db" + defer { try? FileManager.default.removeItem(atPath: tempDB) } + let db = BrainDatabase(path: tempDB) + defer { db.close() } + try db.insertEntity(id: "entity-etan", type: "person", name: "Etan") + try db.insertEntity(id: "entity-brainlayer", type: "project", name: "BrainLayer") + try db.insertRelation(sourceId: "entity-etan", targetId: "entity-brainlayer", relationType: "works_on") + try db.insertChunk( + id: "fallback-kg-result", + content: "Etan BrainLayer fallback result from BrainBar database search", + sessionId: "s1", + project: "brainlayer", + contentType: "assistant_text", + importance: 5 + ) + + let helper = RecordingHybridSearchClient() + let router = MCPRouter(hybridSearchClient: helper) + router.setDatabase(db) + + let response = router.handle([ + "jsonrpc": "2.0", + "id": 172, + "method": "tools/call", + "params": [ + "name": "brain_search", + "arguments": ["query": "Etan BrainLayer", "num_results": 3] + ] as [String: Any] + ]) + + let result = try XCTUnwrap(response["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + let text = content.first?["text"] as? String ?? "" + + XCTAssertEqual(helper.requests.count, 1) + XCTAssertTrue(text.contains("### ◆ Etan"), text) + XCTAssertTrue(text.contains("WORKS_ON"), text) + XCTAssertTrue(text.contains("fallback result from BrainBar database search"), text) + } + func testBrainSearchUnreadOnlyStaysOnBrainBarQueuePathWhenHybridHelperExists() throws { let tempDB = NSTemporaryDirectory() + "brainbar-unread-helper-\(UUID().uuidString).db" defer { try? FileManager.default.removeItem(atPath: tempDB) } diff --git a/src/brainlayer/brainbar_hybrid_helper.py b/src/brainlayer/brainbar_hybrid_helper.py index 998af484..f3ee2646 100644 --- a/src/brainlayer/brainbar_hybrid_helper.py +++ b/src/brainlayer/brainbar_hybrid_helper.py @@ -194,7 +194,6 @@ def main(argv: list[str] | None = None) -> int: from brainlayer.paths import get_db_path helper = HybridSearchHelper(socket_path=Path(args.socket_path), db_path=get_db_path()) - signal.signal(signal.SIGTERM, helper.stop) signal.signal(signal.SIGINT, helper.stop) helper.serve_forever() return 0 From 4b2b4ce89ae8486b1bbf826408a62cd90bf219c9 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 12:04:28 +0300 Subject: [PATCH 19/28] fix: sanitize hybrid helper responses --- .../Sources/BrainBar/BrainBarServer.swift | 6 ++- .../BrainBar/HybridSearchHelperClient.swift | 54 +++++++++++++------ brain-bar/Sources/BrainBar/MCPRouter.swift | 10 +++- .../Tests/BrainBarTests/MCPRouterTests.swift | 8 ++- .../SocketIntegrationTests.swift | 5 +- 5 files changed, 64 insertions(+), 19 deletions(-) diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift index 296896da..7967fc0e 100644 --- a/brain-bar/Sources/BrainBar/BrainBarServer.swift +++ b/brain-bar/Sources/BrainBar/BrainBarServer.swift @@ -290,7 +290,11 @@ final class BrainBarServer: @unchecked Sendable { if let ownedHybridClient { hybridSearchHelperClient = ownedHybridClient - ownedHybridClient.start() + do { + try ownedHybridClient.start() + } catch { + NSLog("[BrainBar] Hybrid search helper startup deferred after failure: %@", String(describing: error)) + } } // 3. NOW open the database (may take time on cold start with 8 GB file). diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index 98c213b2..82a56141 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -17,12 +17,14 @@ protocol HybridSearchClientProtocol: AnyObject, Sendable { final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sendable { private static let maxResponseBytes = 10 * 1024 * 1024 private static let defaultSocketIOTimeout: TimeInterval = 60 + private static let queueKey = DispatchSpecificKey() private let socketPath: String private let dbPath: String private let pythonExecutable: String private let environment: [String: String] private let socketIOTimeout: TimeInterval private let queue = DispatchQueue(label: "com.brainlayer.brainbar.hybrid-helper") + private let queueID = UUID() private var process: Process? init( @@ -37,6 +39,7 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen self.pythonExecutable = pythonExecutable ?? Self.resolvePythonExecutable(environment: environment) self.environment = environment self.socketIOTimeout = socketIOTimeout + queue.setSpecific(key: Self.queueKey, value: queueID) } deinit { @@ -105,17 +108,26 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen return nil } - func start() { - queue.sync { - do { - try startLocked() - } catch { - NSLog("[BrainBar] Hybrid search helper startup deferred after failure: %@", String(describing: error)) - } + var isRunning: Bool { + if DispatchQueue.getSpecific(key: Self.queueKey) == queueID { + return process?.isRunning == true + } + return queue.sync { + process?.isRunning == true + } + } + + func start() throws { + try queue.sync { + try startLocked() } } func stop() { + if DispatchQueue.getSpecific(key: Self.queueKey) == queueID { + stopLocked() + return + } queue.sync { stopLocked() } @@ -136,7 +148,10 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen } private func stopLocked() { - process?.terminate() + if let process, process.isRunning { + process.terminate() + process.waitUntilExit() + } process = nil unlink(socketPath) } @@ -313,9 +328,12 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen private func readLine(fd: Int32) throws -> Data { var result = Data() - var byte = UInt8(0) + let bufferSize = 8192 + var buffer = [UInt8](repeating: 0, count: bufferSize) while true { - let count = read(fd, &byte, 1) + let count = buffer.withUnsafeMutableBytes { rawBuffer in + read(fd, rawBuffer.baseAddress, bufferSize) + } if count < 0 { if errno == EINTR { continue } throw HybridSearchHelperError.read(errno) @@ -323,12 +341,18 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen if count == 0 { break } - if byte == 0x0A { - break + + let chunk = buffer[.. 0 { + if result.count + endIndex > Self.maxResponseBytes { + throw HybridSearchHelperError.responseTooLarge(Self.maxResponseBytes) + } + result.append(contentsOf: buffer[.. Self.maxResponseBytes { - throw HybridSearchHelperError.responseTooLarge(Self.maxResponseBytes) + if newlineIndex != nil { + break } } guard !result.isEmpty else { diff --git a/brain-bar/Sources/BrainBar/MCPRouter.swift b/brain-bar/Sources/BrainBar/MCPRouter.swift index f174d344..31715ae2 100644 --- a/brain-bar/Sources/BrainBar/MCPRouter.swift +++ b/brain-bar/Sources/BrainBar/MCPRouter.swift @@ -312,7 +312,7 @@ final class MCPRouter: @unchecked Sendable { detail: args["detail"] as? String )) textSection = response.text - metadata = response.metadata + metadata = sanitizedHybridMetadata(response.metadata) kgSection = "" } catch { NSLog("[BrainBar] Hybrid search helper failed, falling back to BrainBar database search: %@", String(describing: error)) @@ -335,6 +335,14 @@ final class MCPRouter: @unchecked Sendable { return ToolOutput(text: kgSection + "\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + textSection, metadata: metadata) } + private func sanitizedHybridMetadata(_ metadata: [String: Any]) -> [String: Any] { + var allowed: [String: Any] = [:] + if let structuredContent = metadata["structuredContent"] { + allowed["structuredContent"] = structuredContent + } + return allowed + } + private func hybridSearchArguments( query: String, limit: Int, diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index ec5f48c7..005ce211 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -314,7 +314,11 @@ final class MCPRouterTests: XCTestCase { │ brainlayer │ Michal speakers workshop manual chunk └─ """#, - metadata: ["structuredContent": ["query": "techgym speakers workshop"]] + metadata: [ + "content": "helper must not overwrite MCP content", + "isError": true, + "structuredContent": ["query": "techgym speakers workshop"] + ] ) ) let router = MCPRouter(hybridSearchClient: helper) @@ -353,6 +357,8 @@ final class MCPRouterTests: XCTestCase { XCTAssertEqual(helper.requests.first?["importance_min"] as? Double, 8) XCTAssertEqual(helper.requests.first?["detail"] as? String, "compact") XCTAssertNotNil(result["structuredContent"]) + XCTAssertNil(result["isError"], "Hybrid helper metadata must not overwrite reserved MCP result keys.") + XCTAssertNotNil(result["content"] as? [[String: Any]], "Hybrid helper metadata must not overwrite MCP content.") } func testBrainSearchHybridSuccessDoesNotPrependSwiftKGFacts() throws { diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index 596165fa..33e98ece 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -468,8 +468,11 @@ final class SocketIntegrationTests: XCTestCase { } } let flushDB = BrainDatabase(path: dbPath) - defer { flushDB.close() } server = BrainBarServer(socketPath: testSocketPath, dbPath: dbPath, database: flushDB) + defer { + server.stop() + flushDB.close() + } server.start() Thread.sleep(forTimeInterval: 0.2) From ca64399a146606188e9162e2b35804e316bdd6a5 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 12:10:18 +0300 Subject: [PATCH 20/28] feat: stream BrainBar brain bus events --- brain-bar/Sources/BrainBar/BrainBarApp.swift | 6 +- .../Sources/BrainBar/BrainBarServer.swift | 101 +++++++++- .../Sources/BrainBar/BrainBusClient.swift | 170 ++++++++++++++++ .../Sources/BrainBar/BrainBusEvent.swift | 190 ++++++++++++++++++ .../BrainBar/Dashboard/StatsCollector.swift | 39 ++-- .../BrainBarTests/BrainBusEventHubTests.swift | 78 +++++++ .../Tests/BrainBarTests/DashboardTests.swift | 34 ++++ .../SocketIntegrationTests.swift | 61 ++++++ 8 files changed, 663 insertions(+), 16 deletions(-) create mode 100644 brain-bar/Sources/BrainBar/BrainBusClient.swift create mode 100644 brain-bar/Sources/BrainBar/BrainBusEvent.swift create mode 100644 brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift diff --git a/brain-bar/Sources/BrainBar/BrainBarApp.swift b/brain-bar/Sources/BrainBar/BrainBarApp.swift index 4b17bea3..2a66001a 100644 --- a/brain-bar/Sources/BrainBar/BrainBarApp.swift +++ b/brain-bar/Sources/BrainBar/BrainBarApp.swift @@ -90,7 +90,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.runtime.install( collector: self.collector ?? StatsCollector( dbPath: dbPath, - daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier) + daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + brainBusEvents: BrainBusClient() ), injectionStore: self.injectionStore, database: database @@ -101,7 +102,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let collector = StatsCollector( dbPath: dbPath, - daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier) + daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + brainBusEvents: BrainBusClient() ) let injectionStore = try? InjectionStore(databasePath: dbPath) diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift index 7967fc0e..7a3b1877 100644 --- a/brain-bar/Sources/BrainBar/BrainBarServer.swift +++ b/brain-bar/Sources/BrainBar/BrainBarServer.swift @@ -101,6 +101,8 @@ final class BrainBarServer: @unchecked Sendable { private var hybridSearchHelperClient: HybridSearchHelperClient? private let providedHybridSearchClient: HybridSearchClientProtocol? private let enableHybridSearchHelper: Bool + private let brainBus = BrainBusEventHub() + private var brainBusQueueDepthEstimate = 0 private var databaseRetryWorkItem: DispatchWorkItem? private var lastDatabaseRetryDelayMillis: UInt64? private var databaseOpenInProgress = false @@ -108,8 +110,9 @@ final class BrainBarServer: @unchecked Sendable { var onDatabaseReady: (@Sendable (BrainDatabase) -> Void)? var onStartRejected: (@Sendable (String) -> Void)? /// Maximum EAGAIN retries before disconnecting a stalled client. - /// Each retry sleeps 1ms, so 10 retries = 10ms max blocking the serial queue. - static let maxWriteRetries = 10 + /// Each retry sleeps 1ms; 50 retries keeps false-positive disconnects + /// below the UI budget while still bounding serial-queue stalls. + static let maxWriteRetries = 50 private let debugLogPath = "/tmp/brainbar-debug.log" private func debugLog(_ msg: String) { @@ -138,6 +141,7 @@ final class BrainBarServer: @unchecked Sendable { var usesContentLengthFraming: Bool = true var agentID: String? var subscribedTags: Set = [] + var brainBusSubscriptionID: BrainBusEventHub.SubscriptionID? } init( @@ -342,6 +346,9 @@ final class BrainBarServer: @unchecked Sendable { database = db router.setDatabase(db) onDatabaseReady?(db) + brainBus.publish(.dbBusy(false)) + brainBus.publish(.queueDepth(brainBusQueueDepthEstimate)) + brainBus.publish(.enrichStatus("idle")) NSLog("[BrainBar] Database ready (%@)", dbPath) return } @@ -350,6 +357,7 @@ final class BrainBarServer: @unchecked Sendable { db.close() } NSLog("[BrainBar] ⚠️ DATABASE FAILED TO OPEN — tools/call will return errors (%@)", dbPath) + brainBus.publish(.dbBusy(true)) scheduleDatabaseRetry(lastError: db.lastOpenError) } @@ -421,6 +429,7 @@ final class BrainBarServer: @unchecked Sendable { if !messages.isEmpty { state.usesContentLengthFraming = state.framing.lastExtractUsedContentLength } + clients[fd] = state debugLog("EXTRACTED \(messages.count) messages from fd=\(fd) (framing=\(state.usesContentLengthFraming ? "content-length" : "newline-json"), buffer remaining: \(state.framing.bufferCount) bytes)") for msg in messages { let method = msg["method"] as? String ?? "" @@ -458,11 +467,16 @@ final class BrainBarServer: @unchecked Sendable { if toolCall.name == "brain_store", !isToolError(response) { publishStoredChunks(response: response, arguments: toolCall.arguments) } + if toolCall.name == "brain_enrich", !isToolError(response) { + brainBus.publish(.enrichStatus("running")) + } return response } } if let method = request["method"] as? String { switch method { + case "watch-brain-bus": + return handleWatchBrainBus(fd: fd) default: break } @@ -515,6 +529,74 @@ final class BrainBarServer: @unchecked Sendable { } } + private func writeFramedData(fd: Int32, data: Data) -> Bool { + data.withUnsafeBytes { ptr in + var totalWritten = 0 + var eagainRetries = 0 + while totalWritten < data.count { + let n = write(fd, ptr.baseAddress!.advanced(by: totalWritten), data.count - totalWritten) + if n < 0 { + if errno == EAGAIN || errno == EWOULDBLOCK { + eagainRetries += 1 + if eagainRetries > Self.maxWriteRetries { + return false + } + usleep(1000) + continue + } + return false + } + if n == 0 { + return false + } + totalWritten += n + eagainRetries = 0 + } + return true + } + } + + private func handleWatchBrainBus(fd: Int32) -> [String: Any] { + guard var client = clients[fd] else { return [:] } + if let existing = client.brainBusSubscriptionID { + brainBus.unsubscribe(existing) + } + + let useContentLength = client.usesContentLengthFraming + let subscription = brainBus.subscribe { [weak self] event in + guard let self else { return false } + guard let response = self.brainBusNotification(event: event) else { return true } + let framed: Data + do { + if useContentLength { + framed = try MCPFraming.encode(response) + } else { + var data = try MCPFraming.encodeJSONResponse(response) + data.append(0x0A) + framed = data + } + } catch { + return true + } + + let delivered = self.writeFramedData(fd: fd, data: framed) + if !delivered { + self.queue.async { [weak self] in + self?.disconnectClient(fd: fd) + } + } + return delivered + } + + client.brainBusSubscriptionID = subscription + clients[fd] = client + brainBus.publish(.dbBusy(database == nil)) + brainBus.publish(.queueDepth(brainBusQueueDepthEstimate)) + brainBus.publish(.enrichStatus("idle")) + brainBus.publish(.healthTick(openConnections: clients.count)) + return [:] + } + private static let claudeExtensionRawChunkLimit = 8192 private static func encodeRawJSONResponse(_ response: [String: Any]) -> Data? { @@ -549,6 +631,9 @@ final class BrainBarServer: @unchecked Sendable { } private func disconnectClient(fd: Int32) { + if let subscriptionID = clients[fd]?.brainBusSubscriptionID { + brainBus.unsubscribe(subscriptionID) + } if let agentID = clients[fd]?.agentID { try? database?.markSubscriberDisconnected(agentID: agentID) } @@ -765,6 +850,9 @@ final class BrainBarServer: @unchecked Sendable { } private func publishStoredChunk(stored: StoreResultPayload, content: String, tags: [String], importance: Int) { + brainBusQueueDepthEstimate += 1 + brainBus.publish(.queueDepth(brainBusQueueDepthEstimate)) + brainBus.publish(.lastChunkID(stored.chunkID)) guard !tags.isEmpty else { return } let tagSet = Set(tags) for (clientFD, client) in Array(clients) { @@ -862,4 +950,13 @@ final class BrainBarServer: @unchecked Sendable { } return object } + + private func brainBusNotification(event: BrainBusEvent) -> [String: Any]? { + guard let params = jsonObject(event) else { return nil } + return [ + "jsonrpc": "2.0", + "method": "notifications/brain-bus", + "params": params + ] + } } diff --git a/brain-bar/Sources/BrainBar/BrainBusClient.swift b/brain-bar/Sources/BrainBar/BrainBusClient.swift new file mode 100644 index 00000000..6896bebf --- /dev/null +++ b/brain-bar/Sources/BrainBar/BrainBusClient.swift @@ -0,0 +1,170 @@ +import Darwin +import Foundation + +protocol BrainBusEventSource: Sendable { + func events() -> AsyncStream +} + +final class BrainBusClient: BrainBusEventSource, @unchecked Sendable { + private let socketPath: String + private let reconnectDelay: TimeInterval + private let queue = DispatchQueue(label: "com.brainlayer.brainbar.brain-bus-client") + + init(socketPath: String = BrainBarServer.defaultSocketPath(), reconnectDelay: TimeInterval = 1.0) { + self.socketPath = socketPath + self.reconnectDelay = reconnectDelay + } + + func events() -> AsyncStream { + AsyncStream { continuation in + let run = BrainBusClientRun( + socketPath: socketPath, + reconnectDelay: reconnectDelay, + continuation: continuation + ) + continuation.onTermination = { @Sendable _ in + run.cancel() + } + queue.async { + run.start() + } + } + } +} + +private final class BrainBusClientRun: @unchecked Sendable { + private let socketPath: String + private let reconnectDelay: TimeInterval + private let continuation: AsyncStream.Continuation + private let lock = NSLock() + private var cancelled = false + private var currentFD: Int32 = -1 + + init( + socketPath: String, + reconnectDelay: TimeInterval, + continuation: AsyncStream.Continuation + ) { + self.socketPath = socketPath + self.reconnectDelay = reconnectDelay + self.continuation = continuation + } + + func start() { + while !isCancelled { + autoreleasepool { + if let fd = try? connect() { + setCurrentFD(fd) + sendWatchCommand(fd: fd) + readEvents(fd: fd) + close(fd) + setCurrentFD(-1) + } + } + if !isCancelled { + Thread.sleep(forTimeInterval: reconnectDelay) + } + } + continuation.finish() + } + + func cancel() { + let fd: Int32 = lock.withLock { + cancelled = true + return currentFD + } + if fd >= 0 { + shutdown(fd, SHUT_RDWR) + } + } + + private var isCancelled: Bool { + lock.withLock { cancelled } + } + + private func setCurrentFD(_ fd: Int32) { + lock.withLock { + currentFD = fd + } + } + + private func connect() throws -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw BrainBusClientError.socket(errno) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = socketPath.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + close(fd) + throw BrainBusClientError.socketPathTooLong(socketPath) + } + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in + pathBytes.withUnsafeBufferPointer { src in + _ = memcpy(dest, src.baseAddress!, src.count) + } + } + } + + let result = withUnsafePointer(to: &addr) { addrPtr in + addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { ptr in + Darwin.connect(fd, ptr, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + let code = errno + close(fd) + throw BrainBusClientError.connect(code) + } + return fd + } + + private func sendWatchCommand(fd: Int32) { + let request: [String: Any] = [ + "jsonrpc": "2.0", + "id": 1, + "method": "watch-brain-bus", + ] + guard var data = try? JSONSerialization.data(withJSONObject: request) else { return } + data.append(0x0A) + data.withUnsafeBytes { ptr in + _ = write(fd, ptr.baseAddress!, data.count) + } + } + + private func readEvents(fd: Int32) { + var buffer = Data() + var readBuffer = [UInt8](repeating: 0, count: 8192) + while !isCancelled { + let count = read(fd, &readBuffer, readBuffer.count) + if count > 0 { + buffer.append(contentsOf: readBuffer[0.. BrainBusEvent { + BrainBusEvent(type: .queueDepth, queueDepth: depth) + } + + static func enrichStatus(_ status: String) -> BrainBusEvent { + BrainBusEvent(type: .enrichStatus, enrichStatus: status) + } + + static func lastChunkID(_ chunkID: String) -> BrainBusEvent { + BrainBusEvent(type: .lastChunkID, lastChunkID: chunkID) + } + + static func dbBusy(_ busy: Bool) -> BrainBusEvent { + BrainBusEvent(type: .dbBusy, dbBusy: busy) + } + + static func healthTick(openConnections: Int) -> BrainBusEvent { + BrainBusEvent(type: .healthTick, openConnections: openConnections) + } + + func withSequence(_ sequence: Int, generatedAt: Date = Date()) -> BrainBusEvent { + BrainBusEvent( + type: type, + sequence: sequence, + generatedAt: generatedAt, + queueDepth: queueDepth, + enrichStatus: enrichStatus, + lastChunkID: lastChunkID, + dbBusy: dbBusy, + openConnections: openConnections + ) + } + + private init( + type: BrainBusEventType, + sequence: Int = 0, + generatedAt: Date = Date(), + queueDepth: Int? = nil, + enrichStatus: String? = nil, + lastChunkID: String? = nil, + dbBusy: Bool? = nil, + openConnections: Int? = nil + ) { + self.type = type + self.sequence = sequence + self.generatedAt = generatedAt + self.queueDepth = queueDepth + self.enrichStatus = enrichStatus + self.lastChunkID = lastChunkID + self.dbBusy = dbBusy + self.openConnections = openConnections + } +} + +final class BrainBusEventHub: @unchecked Sendable { + typealias SubscriptionID = UUID + typealias EventWriter = @Sendable (BrainBusEvent) -> Bool + + private struct Subscriber { + let writer: EventWriter + let drainQueue: DispatchQueue + var buffer: [BrainBusEvent] = [] + var isDraining = false + } + + private let queue = DispatchQueue(label: "com.brainlayer.brainbar.brain-bus") + private let bufferCapacity: Int + private var nextSequence = 0 + private var subscribers: [SubscriptionID: Subscriber] = [:] + private var heartbeatTimer: DispatchSourceTimer? + + init(bufferCapacity: Int = 64, heartbeatInterval: TimeInterval? = 5) { + self.bufferCapacity = max(1, bufferCapacity) + if let heartbeatInterval { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + heartbeatInterval, repeating: heartbeatInterval, leeway: .milliseconds(100)) + timer.setEventHandler { [weak self] in + self?.publishOnQueue(.healthTick(openConnections: self?.subscribers.count ?? 0)) + } + timer.resume() + heartbeatTimer = timer + } + } + + deinit { + heartbeatTimer?.cancel() + } + + func subscribe(_ writer: @escaping EventWriter) -> SubscriptionID { + let id = UUID() + queue.sync { + subscribers[id] = Subscriber( + writer: writer, + drainQueue: DispatchQueue(label: "com.brainlayer.brainbar.brain-bus.subscriber.\(id.uuidString)") + ) + } + return id + } + + func unsubscribe(_ id: SubscriptionID) { + queue.async { + self.subscribers.removeValue(forKey: id) + } + } + + func publish(_ event: BrainBusEvent) { + queue.async { + self.publishOnQueue(event) + } + } + + private func publishOnQueue(_ event: BrainBusEvent) { + nextSequence += 1 + let sequenced = event.withSequence(nextSequence) + for id in subscribers.keys { + enqueue(sequenced, for: id) + } + } + + private func enqueue(_ event: BrainBusEvent, for id: SubscriptionID) { + guard var subscriber = subscribers[id] else { return } + if subscriber.buffer.count >= bufferCapacity { + subscriber.buffer.removeFirst() + } + subscriber.buffer.append(event) + let shouldStartDrain = !subscriber.isDraining + if shouldStartDrain { + subscriber.isDraining = true + } + subscribers[id] = subscriber + if shouldStartDrain { + drainNext(for: id) + } + } + + private func drainNext(for id: SubscriptionID) { + guard var subscriber = subscribers[id] else { return } + guard !subscriber.buffer.isEmpty else { + subscriber.isDraining = false + subscribers[id] = subscriber + return + } + let event = subscriber.buffer.removeFirst() + let writer = subscriber.writer + let drainQueue = subscriber.drainQueue + subscribers[id] = subscriber + + drainQueue.async { [weak self] in + let keepOpen = writer(event) + self?.queue.async { [weak self] in + guard keepOpen else { + self?.subscribers.removeValue(forKey: id) + return + } + self?.drainNext(for: id) + } + } + } +} diff --git a/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift b/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift index 0991010b..df62b96d 100644 --- a/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift +++ b/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift @@ -29,18 +29,21 @@ final class StatsCollector: ObservableObject { private let database: BrainDatabase private let daemonMonitor: DaemonHealthMonitor private let agentActivityMonitor: AgentActivityMonitor - private var pollTask: Task? + private let brainBusEvents: BrainBusEventSource? + private var brainBusTask: Task? private var isRunning = false private var lastDataVersion: Int? init( dbPath: String, daemonMonitor: DaemonHealthMonitor, - agentActivityMonitor: AgentActivityMonitor = AgentActivityMonitor() + agentActivityMonitor: AgentActivityMonitor = AgentActivityMonitor(), + brainBusEvents: BrainBusEventSource? = nil ) { self.database = BrainDatabase(path: dbPath) self.daemonMonitor = daemonMonitor self.agentActivityMonitor = agentActivityMonitor + self.brainBusEvents = brainBusEvents self.stats = DashboardStats( chunkCount: 0, enrichedChunkCount: 0, @@ -61,19 +64,24 @@ final class StatsCollector: ObservableObject { guard !isRunning else { return } isRunning = true installDarwinObserver() - refresh(force: true) - pollTask = Task { [weak self] in - while let self, !Task.isCancelled { - try? await Task.sleep(for: .seconds(1)) - guard !Task.isCancelled else { break } - self.pollForChanges() + if let brainBusEvents { + let eventStream = brainBusEvents.events() + brainBusTask = Task { [weak self] in + for await event in eventStream { + guard !Task.isCancelled else { break } + await MainActor.run { + self?.handleBrainBusEvent(event) + } + } } + } else { + refresh(force: true) } } func stop() { - pollTask?.cancel() - pollTask = nil + brainBusTask?.cancel() + brainBusTask = nil if isRunning { removeDarwinObserver() } @@ -120,8 +128,15 @@ final class StatsCollector: ObservableObject { refresh(force: false) } - private func pollForChanges() { - refresh(force: false) + private func handleBrainBusEvent(_ event: BrainBusEvent) { + switch event.type { + case .healthTick: + daemon = daemonMonitor.sample() + agentActivity = agentActivityMonitor.sample() + state = PipelineState.derive(daemon: daemon, stats: stats) + case .queueDepth, .enrichStatus, .lastChunkID, .dbBusy: + refresh(force: false) + } } private func installDarwinObserver() { diff --git a/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift b/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift new file mode 100644 index 00000000..debeb65b --- /dev/null +++ b/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift @@ -0,0 +1,78 @@ +import XCTest +@testable import BrainBar + +final class BrainBusEventHubTests: XCTestCase { + func testSubscribeReceivesThreeStateChangesInOrderWithinLatencyBudget() throws { + let hub = BrainBusEventHub(bufferCapacity: 8, heartbeatInterval: nil) + let received = LockedBrainBusEvents() + let allReceived = XCTestExpectation(description: "received three brain bus events") + + let subscription = hub.subscribe { event in + received.append(event) + if received.count == 3 { + allReceived.fulfill() + } + return true + } + defer { hub.unsubscribe(subscription) } + + let startedAt = DispatchTime.now() + hub.publish(.queueDepth(2)) + hub.publish(.enrichStatus("running")) + hub.publish(.lastChunkID("chunk-3")) + + wait(for: [allReceived], timeout: 1.0) + let elapsedMillis = Double(DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000 + + XCTAssertLessThan(elapsedMillis, 50) + XCTAssertEqual(received.snapshot().map(\.type), [.queueDepth, .enrichStatus, .lastChunkID]) + } + + func testBackpressureDropsOldestEventsWithoutStallingPublisher() throws { + let hub = BrainBusEventHub(bufferCapacity: 3, heartbeatInterval: nil) + let received = LockedBrainBusEvents() + let sawLatest = XCTestExpectation(description: "slow subscriber eventually receives latest event") + + let subscription = hub.subscribe { event in + Thread.sleep(forTimeInterval: 0.02) + received.append(event) + if event.sequence == 20 { + sawLatest.fulfill() + } + return true + } + defer { hub.unsubscribe(subscription) } + + let startedAt = DispatchTime.now() + for index in 1...20 { + hub.publish(.healthTick(openConnections: index)) + } + let publishElapsedMillis = Double(DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000 + + XCTAssertLessThan(publishElapsedMillis, 20) + wait(for: [sawLatest], timeout: 1.0) + + let delivered = received.snapshot() + XCTAssertLessThan(delivered.count, 20) + XCTAssertEqual(delivered.last?.sequence, 20) + } +} + +private final class LockedBrainBusEvents: @unchecked Sendable { + private let lock = NSLock() + private var events: [BrainBusEvent] = [] + + var count: Int { + lock.withLock { events.count } + } + + func append(_ event: BrainBusEvent) { + lock.withLock { + events.append(event) + } + } + + func snapshot() -> [BrainBusEvent] { + lock.withLock { events } + } +} diff --git a/brain-bar/Tests/BrainBarTests/DashboardTests.swift b/brain-bar/Tests/BrainBarTests/DashboardTests.swift index 0a30bbf8..cfd2e4cf 100644 --- a/brain-bar/Tests/BrainBarTests/DashboardTests.swift +++ b/brain-bar/Tests/BrainBarTests/DashboardTests.swift @@ -255,6 +255,24 @@ final class DashboardTests: XCTestCase { XCTAssertEqual(collector.stats.chunkCount, 1) } + @MainActor + func testStatsCollectorSubscribesToBrainBusWithoutPollingDelay() { + let eventSource = RecordingBrainBusEventSource() + let collector = StatsCollector( + dbPath: tempDBPath, + daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + brainBusEvents: eventSource + ) + defer { collector.stop() } + + let startedAt = DispatchTime.now() + collector.start() + let elapsedMillis = Double(DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000 + + XCTAssertEqual(eventSource.streamRequestCount, 1) + XCTAssertLessThan(elapsedMillis, 100) + } + func testPipelineStateTreatsMissingDaemonSnapshotAsDegraded() { let stats = DashboardStats( chunkCount: 10, @@ -387,3 +405,19 @@ final class DashboardTests: XCTestCase { XCTAssertTrue(viewController.isViewLoaded) } } + +private final class RecordingBrainBusEventSource: BrainBusEventSource, @unchecked Sendable { + private let lock = NSLock() + private var requests = 0 + + var streamRequestCount: Int { + lock.withLock { requests } + } + + func events() -> AsyncStream { + lock.withLock { + requests += 1 + } + return AsyncStream { _ in } + } +} diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index 33e98ece..ecfb484c 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -152,6 +152,38 @@ final class SocketIntegrationTests: XCTestCase { } } + func testWatchBrainBusStreamsStoreEventsOverRawUnixSocket() throws { + let watchFD = try connectClient() + defer { close(watchFD) } + + try sendRawLineJSON(on: watchFD, object: [ + "jsonrpc": "2.0", + "id": 50, + "method": "watch-brain-bus", + ]) + _ = try readBrainBusEvent(fd: watchFD, matching: "health_tick") + + let storeResponse = try sendMCPRequest([ + "jsonrpc": "2.0", + "id": 51, + "method": "tools/call", + "params": [ + "name": "brain_store", + "arguments": [ + "content": "Brain bus stream integration", + "tags": ["agent-message"] + ] as [String: Any] + ] + ]) + XCTAssertNil(storeResponse["error"]) + + let event = try readBrainBusEvent(fd: watchFD, matching: "last_chunk_id") + XCTAssertEqual(event["method"] as? String, "notifications/brain-bus") + let params = try XCTUnwrap(event["params"] as? [String: Any]) + XCTAssertEqual(params["type"] as? String, "last_chunk_id") + XCTAssertFalse((params["last_chunk_id"] as? String ?? "").isEmpty) + } + // MARK: - MCP tools/call brain_search over socket func testMCPBrainSearchOverSocket() throws { @@ -952,6 +984,35 @@ final class SocketIntegrationTests: XCTestCase { throw NSError(domain: "test", code: 4, userInfo: [NSLocalizedDescriptionKey: "Timeout reading raw line response"]) } + private func readBrainBusEvent(fd: Int32, matching type: String, timeout: TimeInterval = 5.0) throws -> [String: Any] { + let deadline = Date().addingTimeInterval(timeout) + var buffer = Data() + var readBuf = [UInt8](repeating: 0, count: 65536) + while Date() < deadline { + while let newlineIndex = buffer.firstIndex(of: 0x0A) { + let line = Data(buffer[.. 0 { + buffer.append(contentsOf: readBuf[0.. [String: Any] { return try readMCPMessages(fd: fd, expectedCount: 1, timeout: timeout).first ?? [:] } From 28343e64018c12b8bf7caf56771ffe85bcf19cef Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 12:53:10 +0300 Subject: [PATCH 21/28] refactor: use NSStatusItem popover shell --- brain-bar/Sources/BrainBar/BrainBarApp.swift | 94 +++++++--------- .../BrainBarStatusPopoverController.swift | 104 ++++++++++++++++++ ...BrainBarStatusPopoverControllerTests.swift | 53 +++++++++ 3 files changed, 197 insertions(+), 54 deletions(-) create mode 100644 brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift create mode 100644 brain-bar/Tests/BrainBarTests/BrainBarStatusPopoverControllerTests.swift diff --git a/brain-bar/Sources/BrainBar/BrainBarApp.swift b/brain-bar/Sources/BrainBar/BrainBarApp.swift index 2a66001a..209ecc14 100644 --- a/brain-bar/Sources/BrainBar/BrainBarApp.swift +++ b/brain-bar/Sources/BrainBar/BrainBarApp.swift @@ -7,6 +7,19 @@ enum BrainBarAppSupport { static func hotkeyPermissionFailureMessage(permissions: HotkeyPermissionStatus) -> String { "BrainBar could not start the fallback hotkey listener. Enable \(permissions.missingPermissionsMessage) in System Settings. The CGEventTap fallback requires both Input Monitoring and Accessibility." } + + @MainActor + static func makeStatsCollector( + dbPath: String, + targetPID: pid_t, + brainBusEvents: BrainBusEventSource? = BrainBusClient() + ) -> StatsCollector { + StatsCollector( + dbPath: dbPath, + daemonMonitor: DaemonHealthMonitor(targetPID: targetPID), + brainBusEvents: brainBusEvents + ) + } } @MainActor @@ -15,6 +28,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private static let menuBarWindowAutosaveKey = "NSWindow Frame BrainBarMenuBarExtraWindow" private var server: BrainBarServer? + private var statusPopoverController: BrainBarStatusPopoverController? private var legacyStatusItem: NSStatusItem? private var legacyPopover: NSPopover? private var collector: StatsCollector? @@ -47,11 +61,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { startHotkeyFileWatcher() - if launchMode == .menuBarWindow { - UserDefaults.standard.removeObject(forKey: Self.menuBarWindowAutosaveKey) - dashboardPanel = BrainBarDashboardPanelController(runtime: runtime) - } - let runningInstances = NSRunningApplication.runningApplications( withBundleIdentifier: Bundle.main.bundleIdentifier ?? "com.brainlayer.BrainBar" ) @@ -69,7 +78,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self?.configureQuickCaptureHotkey() } - if launchMode == .legacyStatusItem { + if launchMode == .menuBarWindow { + statusPopoverController = BrainBarStatusPopoverController(runtime: runtime) + } else if launchMode == .legacyStatusItem { createLegacyStatusItem() } @@ -88,9 +99,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.sharedDatabase = database self.configureQuickCapture(database: database) self.runtime.install( - collector: self.collector ?? StatsCollector( + collector: self.collector ?? BrainBarAppSupport.makeStatsCollector( dbPath: dbPath, - daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + targetPID: ProcessInfo.processInfo.processIdentifier, brainBusEvents: BrainBusClient() ), injectionStore: self.injectionStore, @@ -100,9 +111,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } - let collector = StatsCollector( + let collector = BrainBarAppSupport.makeStatsCollector( dbPath: dbPath, - daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + targetPID: ProcessInfo.processInfo.processIdentifier, brainBusEvents: BrainBusClient() ) let injectionStore = try? InjectionStore(databasePath: dbPath) @@ -125,6 +136,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { menuBarWindowObservers.forEach(NotificationCenter.default.removeObserver) menuBarWindowObservers.removeAll() + statusPopoverController?.stop() + statusPopoverController = nil menuBarWindowSyncTask?.cancel() menuBarWindowSyncTask = nil hotkeyFileWatcher?.cancel() @@ -149,7 +162,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } runtime.presentQuickAction(.search) - showMenuBarWindow(nil) + statusPopoverController?.show(nil) } func showQuickCapturePanel() { @@ -159,7 +172,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } runtime.presentQuickAction(.capture) - showMenuBarWindow(nil) + statusPopoverController?.show(nil) } private func configureRuntimeCallbacks() { @@ -238,6 +251,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } + if launchMode == .menuBarWindow, let statusPopoverController { + statusPopoverController.toggle(sender) + return + } + if let dashboardPanel { dashboardPanel.toggle() return @@ -286,10 +304,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } + if let statusPopoverController { + statusPopoverController.show(nil) + return + } + dashboardPanel?.show() } private func showMenuBarWindow(_ sender: Any?) { + if launchMode == .menuBarWindow, let statusPopoverController { + statusPopoverController.show(sender) + return + } + if launchMode == .menuBarWindow, let dashboardPanel { dashboardPanel.show() return @@ -861,26 +889,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @main struct BrainBarApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - private let launchMode = BrainBarLaunchMode.resolve() var body: some Scene { - MenuBarExtra(isInserted: .constant(launchMode == .menuBarWindow)) { - Button("Open Dashboard") { - appDelegate.showDashboardPanel() - } - - Button("Search BrainLayer") { - appDelegate.showSearchPanel() - } - - Button("Capture Note") { - appDelegate.showQuickCapturePanel() - } - } label: { - BrainBarMenuBarLabel(runtime: appDelegate.runtime) - } - .menuBarExtraStyle(.menu) - Settings { EmptyView() } @@ -902,27 +912,3 @@ struct BrainBarApp: App { } } } - -private struct BrainBarMenuBarLabel: View { - @ObservedObject var runtime: BrainBarRuntime - - var body: some View { - if let collector = runtime.collector { - let livePresentation = BrainBarLivePresentation.derive(stats: collector.stats) - HStack(spacing: 6) { - Image(systemName: "brain") - Image( - nsImage: SparklineRenderer.render( - state: collector.state, - values: collector.stats.recentEnrichmentBuckets, - size: NSSize(width: 22, height: 12), - accentColor: livePresentation.accentColor - ) - ) - .interpolation(.high) - } - } else { - Image(systemName: "brain") - } - } -} diff --git a/brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift b/brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift new file mode 100644 index 00000000..7a13da72 --- /dev/null +++ b/brain-bar/Sources/BrainBar/BrainBarStatusPopoverController.swift @@ -0,0 +1,104 @@ +import AppKit +import Combine +import SwiftUI + +@MainActor +final class BrainBarStatusPopoverController: NSObject { + static let contentSize = NSSize(width: 900, height: 640) + + let statusItemForTesting: NSStatusItem + let popoverForTesting: NSPopover + + private let runtime: BrainBarRuntime + private var runtimeCancellables: Set = [] + private var collectorCancellables: Set = [] + + init(runtime: BrainBarRuntime) { + self.runtime = runtime + statusItemForTesting = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + popoverForTesting = NSPopover() + super.init() + + configureStatusItem() + prewarmPopover() + bindRuntime() + } + + func toggle(_ sender: Any?) { + if popoverForTesting.isShown { + popoverForTesting.performClose(sender) + } else { + show(sender) + } + } + + func show(_ sender: Any?) { + guard let button = statusItemForTesting.button else { return } + popoverForTesting.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popoverForTesting.contentViewController?.view.window?.makeKey() + } + + func close(_ sender: Any?) { + popoverForTesting.performClose(sender) + } + + func stop() { + close(nil) + NSStatusBar.system.removeStatusItem(statusItemForTesting) + } + + private func configureStatusItem() { + guard let button = statusItemForTesting.button else { return } + button.image = NSImage(systemSymbolName: "brain", accessibilityDescription: "BrainBar") + button.target = self + button.action = #selector(toggleFromStatusItem(_:)) + button.toolTip = "BrainBar" + } + + private func prewarmPopover() { + popoverForTesting.behavior = .transient + popoverForTesting.contentSize = Self.contentSize + + let hosting = NSHostingController( + rootView: BrainBarWindowRootView(runtime: runtime, managesWindowFrame: false) + .frame(width: Self.contentSize.width, height: Self.contentSize.height) + ) + _ = hosting.view + popoverForTesting.contentViewController = hosting + } + + private func bindRuntime() { + runtime.$collector + .receive(on: RunLoop.main) + .sink { [weak self] collector in + self?.bindCollector(collector) + } + .store(in: &runtimeCancellables) + } + + private func bindCollector(_ collector: StatsCollector?) { + collectorCancellables.removeAll() + guard let collector else { return } + + Publishers.CombineLatest(collector.$stats, collector.$state) + .receive(on: RunLoop.main) + .sink { [weak self] stats, state in + self?.renderStatusIcon(stats: stats, state: state) + } + .store(in: &collectorCancellables) + } + + private func renderStatusIcon(stats: BrainDatabase.DashboardStats, state: PipelineState) { + let livePresentation = BrainBarLivePresentation.derive(stats: stats) + statusItemForTesting.button?.image = SparklineRenderer.render( + state: state, + values: stats.recentEnrichmentBuckets, + size: NSSize(width: 22, height: 12), + accentColor: livePresentation.accentColor + ) + } + + @objc private func toggleFromStatusItem(_ sender: Any?) { + toggle(sender) + } +} diff --git a/brain-bar/Tests/BrainBarTests/BrainBarStatusPopoverControllerTests.swift b/brain-bar/Tests/BrainBarTests/BrainBarStatusPopoverControllerTests.swift new file mode 100644 index 00000000..8b4846be --- /dev/null +++ b/brain-bar/Tests/BrainBarTests/BrainBarStatusPopoverControllerTests.swift @@ -0,0 +1,53 @@ +import AppKit +import XCTest +@testable import BrainBar + +@MainActor +final class BrainBarStatusPopoverControllerTests: XCTestCase { + func testControllerPrewarmsVariableLengthStatusItemPopover() { + let runtime = BrainBarRuntime(launchMode: .menuBarWindow) + let controller = BrainBarStatusPopoverController(runtime: runtime) + defer { controller.stop() } + + XCTAssertEqual(controller.statusItemForTesting.length, NSStatusItem.variableLength) + XCTAssertEqual(controller.popoverForTesting.behavior, NSPopover.Behavior.transient) + XCTAssertNotNil(controller.popoverForTesting.contentViewController) + XCTAssertTrue(controller.popoverForTesting.contentViewController?.isViewLoaded == true) + } + + func testAppSupportCollectorFactoryWiresBrainBusEvents() { + let tempDBPath = NSTemporaryDirectory() + "brainbar-status-popover-\(UUID().uuidString).db" + let eventSource = RecordingBrainBusEventSource() + let collector = BrainBarAppSupport.makeStatsCollector( + dbPath: tempDBPath, + targetPID: ProcessInfo.processInfo.processIdentifier, + brainBusEvents: eventSource + ) + defer { + collector.stop() + try? FileManager.default.removeItem(atPath: tempDBPath) + try? FileManager.default.removeItem(atPath: tempDBPath + "-wal") + try? FileManager.default.removeItem(atPath: tempDBPath + "-shm") + } + + collector.start() + + XCTAssertEqual(eventSource.streamRequestCount, 1) + } +} + +private final class RecordingBrainBusEventSource: BrainBusEventSource, @unchecked Sendable { + private let lock = NSLock() + private var requests = 0 + + var streamRequestCount: Int { + lock.withLock { requests } + } + + func events() -> AsyncStream { + lock.withLock { + requests += 1 + } + return AsyncStream { _ in } + } +} From 17750c23703aef24f43a68550a4e92fbb9ed1b0b Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 12:52:49 +0300 Subject: [PATCH 22/28] fix: prevent SIGPIPE from hybrid helper socket writes --- .../BrainBar/HybridSearchHelperClient.swift | 13 +++++++++++++ .../HybridSearchHelperClientTests.swift | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index 82a56141..369b4186 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -244,6 +244,12 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen guard fd >= 0 else { throw HybridSearchHelperError.socket(errno) } + do { + try Self.configureNoSigpipe(fd: fd) + } catch { + close(fd) + throw error + } var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) @@ -281,6 +287,13 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen throw HybridSearchHelperError.connect(lastErrno) } + static func configureNoSigpipe(fd: Int32) throws { + var nosigpipe: Int32 = 1 + if setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, socklen_t(MemoryLayout.size)) != 0 { + throw HybridSearchHelperError.configureSocket(errno) + } + } + static func configureSocketTimeouts(fd: Int32, timeout: TimeInterval) throws { let boundedTimeout = max(timeout, 0.001) let seconds = Int(boundedTimeout) diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift index ef95c4a3..e5abbd57 100644 --- a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift +++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift @@ -85,4 +85,20 @@ final class HybridSearchHelperClientTests: XCTestCase { XCTAssertTrue(errno == EAGAIN || errno == EWOULDBLOCK) XCTAssertLessThan(elapsed, 1.0) } + + func testConfigureNoSigpipeAcceptsOpenUnixSocket() throws { + var fds: [Int32] = [0, 0] + XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) + defer { + close(fds[0]) + close(fds[1]) + } + + try HybridSearchHelperClient.configureNoSigpipe(fd: fds[0]) + + var value: Int32 = 0 + var length = socklen_t(MemoryLayout.size) + XCTAssertEqual(getsockopt(fds[0], SOL_SOCKET, SO_NOSIGPIPE, &value, &length), 0) + XCTAssertEqual(value, 1) + } } From 6348695766eeee933b1104edde45e50cb878777d Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 13:15:58 +0300 Subject: [PATCH 23/28] fix: serialize BrainBus socket writes --- .../Sources/BrainBar/BrainBarServer.swift | 23 ++++++++++++++++--- .../Sources/BrainBar/BrainBusEvent.swift | 13 +++++++++++ .../BrainBar/HybridSearchHelperClient.swift | 17 ++++++++++---- .../BrainBarTests/BrainBusEventHubTests.swift | 15 ++++++++++++ .../HybridSearchHelperClientTests.swift | 22 +++++++++++++++++- 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift index 7a3b1877..6a54dfc0 100644 --- a/brain-bar/Sources/BrainBar/BrainBarServer.swift +++ b/brain-bar/Sources/BrainBar/BrainBarServer.swift @@ -92,7 +92,9 @@ final class BrainBarServer: @unchecked Sendable { private let providedDatabase: BrainDatabase? private let databaseRecoveryPolicy: DatabaseRecoveryPolicy private let instanceLockPath: String + private static let queueKey = DispatchSpecificKey() private let queue = DispatchQueue(label: "com.brainlayer.brainbar.server", qos: .userInitiated) + private let queueID = UUID() private var listenFD: Int32 = -1 private var listenSource: DispatchSourceRead? private var clients: [Int32: ClientState] = [:] @@ -160,6 +162,7 @@ final class BrainBarServer: @unchecked Sendable { self.enableHybridSearchHelper = enableHybridSearchHelper self.databaseRecoveryPolicy = databaseRecoveryPolicy self.instanceLockPath = instanceLockPath ?? Self.defaultInstanceLockPath(socketPath: self.socketPath) + queue.setSpecific(key: Self.queueKey, value: queueID) } static func defaultSocketPath() -> String { @@ -556,10 +559,21 @@ final class BrainBarServer: @unchecked Sendable { } } + private func sendBrainBusFrame(fd: Int32, data: Data) -> Bool { + if DispatchQueue.getSpecific(key: Self.queueKey) == queueID { + guard clients[fd] != nil else { return false } + return writeFramedData(fd: fd, data: data) + } + return queue.sync { + guard clients[fd] != nil else { return false } + return writeFramedData(fd: fd, data: data) + } + } + private func handleWatchBrainBus(fd: Int32) -> [String: Any] { guard var client = clients[fd] else { return [:] } if let existing = client.brainBusSubscriptionID { - brainBus.unsubscribe(existing) + brainBus.unsubscribeSynchronously(existing) } let useContentLength = client.usesContentLengthFraming @@ -579,7 +593,7 @@ final class BrainBarServer: @unchecked Sendable { return true } - let delivered = self.writeFramedData(fd: fd, data: framed) + let delivered = self.sendBrainBusFrame(fd: fd, data: framed) if !delivered { self.queue.async { [weak self] in self?.disconnectClient(fd: fd) @@ -632,7 +646,7 @@ final class BrainBarServer: @unchecked Sendable { private func disconnectClient(fd: Int32) { if let subscriptionID = clients[fd]?.brainBusSubscriptionID { - brainBus.unsubscribe(subscriptionID) + brainBus.unsubscribeSynchronously(subscriptionID) } if let agentID = clients[fd]?.agentID { try? database?.markSubscriberDisconnected(agentID: agentID) @@ -648,6 +662,9 @@ final class BrainBarServer: @unchecked Sendable { listenSource?.cancel() listenSource = nil for (_, state) in clients { + if let subscriptionID = state.brainBusSubscriptionID { + brainBus.unsubscribeSynchronously(subscriptionID) + } state.source.cancel() } clients.removeAll() diff --git a/brain-bar/Sources/BrainBar/BrainBusEvent.swift b/brain-bar/Sources/BrainBar/BrainBusEvent.swift index 419813cc..ac7718be 100644 --- a/brain-bar/Sources/BrainBar/BrainBusEvent.swift +++ b/brain-bar/Sources/BrainBar/BrainBusEvent.swift @@ -94,7 +94,9 @@ final class BrainBusEventHub: @unchecked Sendable { var isDraining = false } + private static let queueKey = DispatchSpecificKey() private let queue = DispatchQueue(label: "com.brainlayer.brainbar.brain-bus") + private let queueID = UUID() private let bufferCapacity: Int private var nextSequence = 0 private var subscribers: [SubscriptionID: Subscriber] = [:] @@ -102,6 +104,7 @@ final class BrainBusEventHub: @unchecked Sendable { init(bufferCapacity: Int = 64, heartbeatInterval: TimeInterval? = 5) { self.bufferCapacity = max(1, bufferCapacity) + queue.setSpecific(key: Self.queueKey, value: queueID) if let heartbeatInterval { let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + heartbeatInterval, repeating: heartbeatInterval, leeway: .milliseconds(100)) @@ -134,6 +137,16 @@ final class BrainBusEventHub: @unchecked Sendable { } } + func unsubscribeSynchronously(_ id: SubscriptionID) { + if DispatchQueue.getSpecific(key: Self.queueKey) == queueID { + subscribers.removeValue(forKey: id) + return + } + _ = queue.sync { + self.subscribers.removeValue(forKey: id) + } + } + func publish(_ event: BrainBusEvent) { queue.async { self.publishOnQueue(event) diff --git a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift index 369b4186..770aee75 100644 --- a/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift +++ b/brain-bar/Sources/BrainBar/HybridSearchHelperClient.swift @@ -161,6 +161,7 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen return } + try Self.validateSocketPath(socketPath) unlink(socketPath) let proc = Process() @@ -253,11 +254,7 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) - let pathBytes = socketPath.utf8CString - guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { - close(fd) - throw HybridSearchHelperError.socketPathTooLong(socketPath) - } + let pathBytes = try Self.validateSocketPath(socketPath) withUnsafeMutablePointer(to: &addr.sun_path) { ptr in ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in pathBytes.withUnsafeBufferPointer { src in @@ -287,6 +284,16 @@ final class HybridSearchHelperClient: HybridSearchClientProtocol, @unchecked Sen throw HybridSearchHelperError.connect(lastErrno) } + @discardableResult + static func validateSocketPath(_ path: String) throws -> ContiguousArray { + let pathBytes = path.utf8CString + let addr = sockaddr_un() + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + throw HybridSearchHelperError.socketPathTooLong(path) + } + return ContiguousArray(pathBytes) + } + static func configureNoSigpipe(fd: Int32) throws { var nosigpipe: Int32 = 1 if setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, socklen_t(MemoryLayout.size)) != 0 { diff --git a/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift b/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift index debeb65b..0e17cf3c 100644 --- a/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift +++ b/brain-bar/Tests/BrainBarTests/BrainBusEventHubTests.swift @@ -56,6 +56,21 @@ final class BrainBusEventHubTests: XCTestCase { XCTAssertLessThan(delivered.count, 20) XCTAssertEqual(delivered.last?.sequence, 20) } + + func testSynchronousUnsubscribeRemovesSubscriberBeforeReturn() throws { + let hub = BrainBusEventHub(bufferCapacity: 8, heartbeatInterval: nil) + let received = LockedBrainBusEvents() + let subscription = hub.subscribe { event in + received.append(event) + return true + } + + hub.unsubscribeSynchronously(subscription) + hub.publish(.queueDepth(1)) + Thread.sleep(forTimeInterval: 0.05) + + XCTAssertEqual(received.count, 0) + } } private final class LockedBrainBusEvents: @unchecked Sendable { diff --git a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift index e5abbd57..e5706fc8 100644 --- a/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift +++ b/brain-bar/Tests/BrainBarTests/HybridSearchHelperClientTests.swift @@ -50,7 +50,7 @@ final class HybridSearchHelperClientTests: XCTestCase { func testSearchReportsLaunchFailureWithoutSocketRetry() throws { let client = HybridSearchHelperClient( - socketPath: NSTemporaryDirectory() + "brainbar-missing-helper-\(UUID().uuidString).sock", + socketPath: "/tmp/bb-missing-\(UUID().uuidString).sock", dbPath: "/tmp/brainlayer-test.db", pythonExecutable: "/no/such/python", environment: [:] @@ -66,6 +66,26 @@ final class HybridSearchHelperClientTests: XCTestCase { } } + func testSearchRejectsTooLongSocketPathBeforeLaunch() throws { + let longPath = "/tmp/" + String(repeating: "x", count: 200) + ".sock" + let client = HybridSearchHelperClient( + socketPath: longPath, + dbPath: "/tmp/brainlayer-test.db", + pythonExecutable: "/no/such/python", + environment: [:] + ) + + do { + _ = try client.search(arguments: ["query": "techgym speakers workshop"]) + XCTFail("Expected socket path validation failure") + } catch let error as HybridSearchHelperError { + guard case .socketPathTooLong(let path) = error else { + return XCTFail("Expected socket path error, got \(error)") + } + XCTAssertEqual(path, longPath) + } + } + func testConfigureSocketTimeoutsMakesReadsFinite() throws { var fds: [Int32] = [0, 0] XCTAssertEqual(socketpair(AF_UNIX, SOCK_STREAM, 0, &fds), 0) From fed97bb31fa7b57cbaa2e53a7282b5d179bd14d0 Mon Sep 17 00:00:00 2001 From: Etan Heyman <93338119+EtanHey@users.noreply.github.com> Date: Mon, 18 May 2026 13:26:39 +0300 Subject: [PATCH 24/28] refactor: split BrainBar daemon and UI (#298) --- README.md | 16 +++- brain-bar/Package.swift | 14 +++ brain-bar/Sources/BrainBar/BrainBarApp.swift | 83 +++++------------ .../Sources/BrainBar/BrainBarRuntime.swift | 2 +- .../Sources/BrainBar/BrainDatabase.swift | 7 +- .../BrainBarDaemon/BrainBarCommandBar.swift | 1 + .../BrainBarDaemon/BrainBarDaemonMain.swift | 18 ++++ .../BrainBarDashboardPanelController.swift | 1 + .../BrainBarDaemon/BrainBarInstanceLock.swift | 1 + .../BrainBarDaemon/BrainBarRuntime.swift | 1 + .../BrainBarDaemon/BrainBarServer.swift | 1 + .../BrainBarStatusPopoverController.swift | 1 + .../BrainBarDaemon/BrainBarURLAction.swift | 1 + .../BrainBarWindowRootView.swift | 1 + .../BrainBarDaemon/BrainBarWindowState.swift | 1 + .../BrainBarDaemon/BrainBusClient.swift | 1 + .../BrainBarDaemon/BrainBusEvent.swift | 1 + .../BrainBarDaemon/BrainDatabase.swift | 1 + .../Dashboard/AgentActivityMonitor.swift | 1 + .../Dashboard/BrainBarLivePulse.swift | 1 + .../Dashboard/DaemonHealthMonitor.swift | 1 + .../Dashboard/DashboardMetricFormatter.swift | 1 + .../Dashboard/PipelineState.swift | 1 + .../Dashboard/SparklineRenderer.swift | 1 + .../Dashboard/StatsCollector.swift | 1 + .../Dashboard/StatusPopoverView.swift | 1 + .../Sources/BrainBarDaemon/Formatters.swift | 1 + .../Formatting/TextFormatter.swift | 1 + .../BrainBarDaemon/HotkeyManager.swift | 1 + .../BrainBarDaemon/HotkeyRouteStatus.swift | 1 + .../HybridSearchHelperClient.swift | 1 + .../BrainBarDaemon/InjectionEvent.swift | 1 + .../BrainBarDaemon/InjectionFeedView.swift | 1 + .../InjectionPresentation.swift | 1 + .../BrainBarDaemon/InjectionStore.swift | 1 + .../BrainBarDaemon/InjectionSummaryView.swift | 1 + .../KnowledgeGraph/EntityCache.swift | 1 + .../KnowledgeGraph/KGAtlasLayout.swift | 1 + .../KnowledgeGraph/KGAtlasPresentation.swift | 1 + .../KnowledgeGraph/KGCanvasView.swift | 1 + .../KnowledgeGraph/KGEdge.swift | 1 + .../KnowledgeGraph/KGEdgeView.swift | 1 + .../KnowledgeGraph/KGNode.swift | 1 + .../KnowledgeGraph/KGNodeView.swift | 1 + .../KnowledgeGraph/KGSidebarView.swift | 1 + .../KGSimulationController.swift | 1 + .../KnowledgeGraph/KGViewModel.swift | 1 + .../KnowledgeGraph/ScrollWheelZoomView.swift | 1 + .../Sources/BrainBarDaemon/MCPFraming.swift | 1 + .../Sources/BrainBarDaemon/MCPRouter.swift | 1 + .../BrainBarDaemon/Models/DigestResult.swift | 1 + .../BrainBarDaemon/Models/EntityCard.swift | 1 + .../Models/KGSearchResult.swift | 1 + .../BrainBarDaemon/Models/SearchResult.swift | 1 + .../BrainBarDaemon/Models/StatsResult.swift | 1 + .../QuickCaptureController.swift | 1 + .../BrainBarDaemon/QuickCapturePanel.swift | 1 + .../QuickCapturePanelState.swift | 1 + .../BrainBarDaemon/SearchFilters.swift | 1 + .../SearchPanelController.swift | 1 + .../BrainBarDaemon/SearchQueryActor.swift | 1 + .../BrainBarDaemon/SearchViewModel.swift | 1 + .../Components/ChunkConversationSheet.swift | 1 + .../Views/Components/SearchResultCard.swift | 1 + .../Views/Components/SearchResultsList.swift | 1 + .../Tests/BrainBarTests/DatabaseTests.swift | 30 ++++++ brain-bar/build-app.sh | 91 ++++++++++++++----- .../com.brainlayer.brainbar-daemon.plist | 24 +++++ tests/test_brainbar_build_app_guards.py | 24 ++++- 69 files changed, 278 insertions(+), 90 deletions(-) create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainBarCommandBar.swift create mode 100644 brain-bar/Sources/BrainBarDaemon/BrainBarDaemonMain.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainBarDashboardPanelController.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainBarInstanceLock.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainBarRuntime.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainBarServer.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainBarStatusPopoverController.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainBarURLAction.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainBarWindowRootView.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainBarWindowState.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainBusClient.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainBusEvent.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/BrainDatabase.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Dashboard/AgentActivityMonitor.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Dashboard/BrainBarLivePulse.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Dashboard/DaemonHealthMonitor.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Dashboard/DashboardMetricFormatter.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Dashboard/PipelineState.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Dashboard/SparklineRenderer.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Dashboard/StatsCollector.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Dashboard/StatusPopoverView.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Formatters.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Formatting/TextFormatter.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/HotkeyManager.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/HotkeyRouteStatus.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/HybridSearchHelperClient.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/InjectionEvent.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/InjectionFeedView.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/InjectionPresentation.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/InjectionStore.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/InjectionSummaryView.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/EntityCache.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasLayout.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasPresentation.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGCanvasView.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdge.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdgeView.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNode.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNodeView.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSidebarView.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSimulationController.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGViewModel.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/ScrollWheelZoomView.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/MCPFraming.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/MCPRouter.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Models/DigestResult.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Models/EntityCard.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Models/KGSearchResult.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Models/SearchResult.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Models/StatsResult.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/QuickCaptureController.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/QuickCapturePanel.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/QuickCapturePanelState.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/SearchFilters.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/SearchPanelController.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/SearchQueryActor.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/SearchViewModel.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Views/Components/ChunkConversationSheet.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultCard.swift create mode 120000 brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultsList.swift create mode 100644 brain-bar/bundle/com.brainlayer.brainbar-daemon.plist diff --git a/README.md b/README.md index 79b0edbf..61ab0e62 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,8 @@ graph LR KG --> D E["JSONL conversations"] --> W["Real-time Watcher
~1s latency"] W --> D - I["BrainBar
macOS menu bar"] -->|Unix socket| B + I["BrainBar UI
NSStatusItem + NSPopover"] -->|UDS /tmp/brainbar.sock| BB["BrainBarDaemon
MCP + brain bus"] + BB -->|MCP socket protocol| B ``` **Everything runs locally.** Cloud enrichment (Gemini/Groq) and Axiom telemetry are optional. @@ -138,13 +139,22 @@ graph LR ## BrainBar — macOS Companion -Optional native Swift menu bar app. Quick capture, live dashboard, knowledge graph viewer — all over a Unix socket. Auto-restarts after quit via LaunchAgent. +Optional native Swift menu bar companion split into two launchd-managed processes: + +```mermaid +flowchart LR + UI["BrainBar
LSUIElement UI"] -->|"watch-brain-bus + commands
/tmp/brainbar.sock"| D["BrainBarDaemon
headless MCP server"] + D -->|"single writer queue + reads"| DB["SQLite WAL
~/.local/share/brainlayer/brainlayer.db"] + D -->|"helper subprocess IPC"| H["Hybrid search helper"] +``` + +`BrainBarDaemon` owns the MCP server, `/tmp/brainbar.sock`, the single-writer path, the `watch-brain-bus` stream, and helper subprocess lifecycle. `BrainBar` owns only the `NSStatusItem`, transient `NSPopover`, SwiftUI surfaces, hotkey routing, and a reconnecting socket subscriber. Killing the UI does not stop the daemon socket. ```bash bash brain-bar/build-app.sh # Build, sign, install LaunchAgent ``` -Requires the BrainLayer MCP server. The build script refuses non-canonical checkouts and dirty trees by default ([#265](https://github.com/EtanHey/brainlayer/pull/265)) and stamps each bundle with `GitCommit`, `GitDescribe`, and `BuildTimeUTC` in `Info.plist` ([#264](https://github.com/EtanHey/brainlayer/pull/264)) so a stale install is diagnosable in seconds. +The build script builds both `BrainBar` and `BrainBarDaemon`, embeds both binaries in `BrainBar.app`, then installs `com.brainlayer.brainbar.plist` and `com.brainlayer.brainbar-daemon.plist` with `ProcessType=Interactive`. It refuses non-canonical checkouts and dirty trees by default ([#265](https://github.com/EtanHey/brainlayer/pull/265)) and stamps each bundle with `GitCommit`, `GitDescribe`, and `BuildTimeUTC` in `Info.plist` ([#264](https://github.com/EtanHey/brainlayer/pull/264)) so a stale install is diagnosable in seconds. ## Writer Arbitration diff --git a/brain-bar/Package.swift b/brain-bar/Package.swift index d362124a..8a996a0f 100644 --- a/brain-bar/Package.swift +++ b/brain-bar/Package.swift @@ -6,6 +6,10 @@ let package = Package( platforms: [ .macOS(.v14), ], + products: [ + .executable(name: "BrainBar", targets: ["BrainBar"]), + .executable(name: "BrainBarDaemon", targets: ["BrainBarDaemon"]), + ], dependencies: [ .package(url: "https://github.com/groue/GRDB.swift.git", from: "7.5.0"), ], @@ -20,6 +24,16 @@ let package = Package( .linkedLibrary("sqlite3"), ] ), + .executableTarget( + name: "BrainBarDaemon", + dependencies: [ + .product(name: "GRDB", package: "GRDB.swift"), + ], + path: "Sources/BrainBarDaemon", + linkerSettings: [ + .linkedLibrary("sqlite3"), + ] + ), .testTarget( name: "BrainBarTests", dependencies: [ diff --git a/brain-bar/Sources/BrainBar/BrainBarApp.swift b/brain-bar/Sources/BrainBar/BrainBarApp.swift index 209ecc14..dc223ee0 100644 --- a/brain-bar/Sources/BrainBar/BrainBarApp.swift +++ b/brain-bar/Sources/BrainBar/BrainBarApp.swift @@ -20,6 +20,18 @@ enum BrainBarAppSupport { brainBusEvents: brainBusEvents ) } + + @MainActor + static func makeUIStatsCollector( + dbPath: String, + brainBusEvents: BrainBusEventSource? = BrainBusClient() + ) -> StatsCollector { + makeStatsCollector( + dbPath: dbPath, + targetPID: 0, + brainBusEvents: brainBusEvents + ) + } } @MainActor @@ -27,20 +39,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let runtime = BrainBarRuntime() private static let menuBarWindowAutosaveKey = "NSWindow Frame BrainBarMenuBarExtraWindow" - private var server: BrainBarServer? private var statusPopoverController: BrainBarStatusPopoverController? private var legacyStatusItem: NSStatusItem? private var legacyPopover: NSPopover? private var collector: StatsCollector? - private var injectionStore: InjectionStore? - private var quickCapturePanel: QuickCapturePanelController? private var searchPanel: SearchPanelController? private var dashboardPanel: BrainBarDashboardPanelController? private var quickCaptureHotkey: HotkeyManager? private weak var menuBarExtraWindow: NSWindow? private weak var discoveredMenuBarWindow: NSWindow? private var cancellables: Set = [] - private var sharedDatabase: BrainDatabase? private var pendingBrainBarURLs: [URL] = [] private var hotkeyFileWatcher: DispatchSourceFileSystemObject? private var menuBarWindowObservers: [NSObjectProtocol] = [] @@ -85,44 +93,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } let dbPath = BrainBarServer.defaultDBPath() - NSLog("[BrainBar] Starting server before database readiness at %@", dbPath) - let server = BrainBarServer(dbPath: dbPath) - server.onStartRejected = { reason in - NSLog("[BrainBar] Startup rejected: %@", reason) - Task { @MainActor in - NSApp.terminate(nil) - } - } - server.onDatabaseReady = { [weak self] database in - Task { @MainActor in - guard let self else { return } - self.sharedDatabase = database - self.configureQuickCapture(database: database) - self.runtime.install( - collector: self.collector ?? BrainBarAppSupport.makeStatsCollector( - dbPath: dbPath, - targetPID: ProcessInfo.processInfo.processIdentifier, - brainBusEvents: BrainBusClient() - ), - injectionStore: self.injectionStore, - database: database - ) - self.flushPendingBrainBarURLs() - } - } - - let collector = BrainBarAppSupport.makeStatsCollector( + NSLog("[BrainBar] Starting UI shell; daemon owns %@", BrainBarServer.defaultSocketPath()) + let collector = BrainBarAppSupport.makeUIStatsCollector( dbPath: dbPath, - targetPID: ProcessInfo.processInfo.processIdentifier, brainBusEvents: BrainBusClient() ) - let injectionStore = try? InjectionStore(databasePath: dbPath) - - self.server = server self.collector = collector - self.injectionStore = injectionStore + runtime.install( + collector: collector, + injectionStore: nil, + database: nil + ) - server.start() + flushPendingBrainBarURLs() if launchMode == .legacyStatusItem { installLegacyMenuBarSurface(with: collector) @@ -143,8 +126,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { hotkeyFileWatcher?.cancel() quickCaptureHotkey?.stop() collector?.stop() - injectionStore?.stop() - server?.stop() } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -167,7 +148,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func showQuickCapturePanel() { guard launchMode == .menuBarWindow else { - quickCapturePanel?.toggle() + NSLog("[BrainBar] Quick capture requires the menu-bar command surface in UI-split mode") return } @@ -827,16 +808,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } - private func configureQuickCapture(database: BrainDatabase) { - guard launchMode == .legacyStatusItem else { return } - guard quickCapturePanel == nil else { return } - quickCapturePanel = QuickCapturePanelController(db: database) - if searchPanel == nil { - searchPanel = SearchPanelController(db: database) - } - flushPendingBrainBarURLs() - } - private func ingestBrainBarURLs(_ urls: [URL]) { for url in urls { guard BrainBarURLAction.parse(url: url) != nil else { continue } @@ -857,18 +828,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } - /// URL actions are dispatched once the backing surface is ready. In - /// menuBarWindow mode the command bar is driven by `runtime.database` + - /// the MenuBarExtra window, so readiness means the database has been - /// installed into the runtime. In legacy mode the floating panel still - /// drives routing. private func isReadyToHandleBrainBarURL() -> Bool { - switch launchMode { - case .menuBarWindow: - return runtime.database != nil - case .legacyStatusItem: - return quickCapturePanel != nil - } + true } private func handleBrainBarURL(_ url: URL) { diff --git a/brain-bar/Sources/BrainBar/BrainBarRuntime.swift b/brain-bar/Sources/BrainBar/BrainBarRuntime.swift index 0068446d..a21ee3bc 100644 --- a/brain-bar/Sources/BrainBar/BrainBarRuntime.swift +++ b/brain-bar/Sources/BrainBar/BrainBarRuntime.swift @@ -26,7 +26,7 @@ final class BrainBarRuntime: ObservableObject { func install( collector: StatsCollector, injectionStore: InjectionStore?, - database: BrainDatabase + database: BrainDatabase? ) { self.collector = collector self.injectionStore = injectionStore diff --git a/brain-bar/Sources/BrainBar/BrainDatabase.swift b/brain-bar/Sources/BrainBar/BrainDatabase.swift index 22e8582d..9bb91ea8 100644 --- a/brain-bar/Sources/BrainBar/BrainDatabase.swift +++ b/brain-bar/Sources/BrainBar/BrainDatabase.swift @@ -1967,9 +1967,14 @@ final class BrainDatabase: @unchecked Sendable { try execute(""" CREATE TABLE IF NOT EXISTS schema_migrations ( name TEXT PRIMARY KEY, - applied_at TEXT NOT NULL + applied_at TEXT NOT NULL, + details TEXT ) """) + let schemaMigrationColumns = try tableColumns(name: "schema_migrations", on: db) + if !schemaMigrationColumns.contains("details") { + try execute("ALTER TABLE schema_migrations ADD COLUMN details TEXT") + } let existingColumns = try tableColumns(name: "chunks", on: db) let atomicColumns = ["brick_id", "source_uri", "status", "ingested_at", "topic_cluster"] let atomicMigrationApplied = try schemaMigrationApplied(name: "atomic_brick_chunks_v1", on: db) diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarCommandBar.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarCommandBar.swift new file mode 120000 index 00000000..ffb642e3 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBarCommandBar.swift @@ -0,0 +1 @@ +../BrainBar/BrainBarCommandBar.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarDaemonMain.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarDaemonMain.swift new file mode 100644 index 00000000..679611cb --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBarDaemonMain.swift @@ -0,0 +1,18 @@ +import Foundation + +@main +enum BrainBarDaemonMain { + static func main() { + let server = BrainBarServer() + server.onStartRejected = { reason in + NSLog("[BrainBarDaemon] Startup rejected: %@", reason) + Foundation.exit(1) + } + server.onDatabaseReady = { _ in + NSLog("[BrainBarDaemon] Database ready") + } + server.start() + NSLog("[BrainBarDaemon] Started on %@", BrainBarServer.defaultSocketPath()) + RunLoop.main.run() + } +} diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarDashboardPanelController.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarDashboardPanelController.swift new file mode 120000 index 00000000..d6a1b54f --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBarDashboardPanelController.swift @@ -0,0 +1 @@ +../BrainBar/BrainBarDashboardPanelController.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarInstanceLock.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarInstanceLock.swift new file mode 120000 index 00000000..9ee0500b --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBarInstanceLock.swift @@ -0,0 +1 @@ +../BrainBar/BrainBarInstanceLock.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarRuntime.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarRuntime.swift new file mode 120000 index 00000000..9f09c04f --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBarRuntime.swift @@ -0,0 +1 @@ +../BrainBar/BrainBarRuntime.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarServer.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarServer.swift new file mode 120000 index 00000000..049d137c --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBarServer.swift @@ -0,0 +1 @@ +../BrainBar/BrainBarServer.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarStatusPopoverController.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarStatusPopoverController.swift new file mode 120000 index 00000000..af4bdfbb --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBarStatusPopoverController.swift @@ -0,0 +1 @@ +../BrainBar/BrainBarStatusPopoverController.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarURLAction.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarURLAction.swift new file mode 120000 index 00000000..972de793 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBarURLAction.swift @@ -0,0 +1 @@ +../BrainBar/BrainBarURLAction.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarWindowRootView.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarWindowRootView.swift new file mode 120000 index 00000000..3ba246b7 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBarWindowRootView.swift @@ -0,0 +1 @@ +../BrainBar/BrainBarWindowRootView.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBarWindowState.swift b/brain-bar/Sources/BrainBarDaemon/BrainBarWindowState.swift new file mode 120000 index 00000000..c7104bad --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBarWindowState.swift @@ -0,0 +1 @@ +../BrainBar/BrainBarWindowState.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBusClient.swift b/brain-bar/Sources/BrainBarDaemon/BrainBusClient.swift new file mode 120000 index 00000000..178878fb --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBusClient.swift @@ -0,0 +1 @@ +../BrainBar/BrainBusClient.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/BrainBusEvent.swift b/brain-bar/Sources/BrainBarDaemon/BrainBusEvent.swift new file mode 120000 index 00000000..e7e0a2eb --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainBusEvent.swift @@ -0,0 +1 @@ +../BrainBar/BrainBusEvent.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/BrainDatabase.swift b/brain-bar/Sources/BrainBarDaemon/BrainDatabase.swift new file mode 120000 index 00000000..04590daa --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/BrainDatabase.swift @@ -0,0 +1 @@ +../BrainBar/BrainDatabase.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/AgentActivityMonitor.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/AgentActivityMonitor.swift new file mode 120000 index 00000000..7b82ac34 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/AgentActivityMonitor.swift @@ -0,0 +1 @@ +../../BrainBar/Dashboard/AgentActivityMonitor.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/BrainBarLivePulse.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/BrainBarLivePulse.swift new file mode 120000 index 00000000..8c3364a5 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/BrainBarLivePulse.swift @@ -0,0 +1 @@ +../../BrainBar/Dashboard/BrainBarLivePulse.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/DaemonHealthMonitor.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/DaemonHealthMonitor.swift new file mode 120000 index 00000000..e4016c38 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/DaemonHealthMonitor.swift @@ -0,0 +1 @@ +../../BrainBar/Dashboard/DaemonHealthMonitor.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/DashboardMetricFormatter.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/DashboardMetricFormatter.swift new file mode 120000 index 00000000..70b250ff --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/DashboardMetricFormatter.swift @@ -0,0 +1 @@ +../../BrainBar/Dashboard/DashboardMetricFormatter.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/PipelineState.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/PipelineState.swift new file mode 120000 index 00000000..f42036fa --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/PipelineState.swift @@ -0,0 +1 @@ +../../BrainBar/Dashboard/PipelineState.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/SparklineRenderer.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/SparklineRenderer.swift new file mode 120000 index 00000000..2b0c227e --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/SparklineRenderer.swift @@ -0,0 +1 @@ +../../BrainBar/Dashboard/SparklineRenderer.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/StatsCollector.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/StatsCollector.swift new file mode 120000 index 00000000..02f5bbb8 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/StatsCollector.swift @@ -0,0 +1 @@ +../../BrainBar/Dashboard/StatsCollector.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Dashboard/StatusPopoverView.swift b/brain-bar/Sources/BrainBarDaemon/Dashboard/StatusPopoverView.swift new file mode 120000 index 00000000..165246f4 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Dashboard/StatusPopoverView.swift @@ -0,0 +1 @@ +../../BrainBar/Dashboard/StatusPopoverView.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Formatters.swift b/brain-bar/Sources/BrainBarDaemon/Formatters.swift new file mode 120000 index 00000000..22a91276 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Formatters.swift @@ -0,0 +1 @@ +../BrainBar/Formatters.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Formatting/TextFormatter.swift b/brain-bar/Sources/BrainBarDaemon/Formatting/TextFormatter.swift new file mode 120000 index 00000000..88ae46ab --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Formatting/TextFormatter.swift @@ -0,0 +1 @@ +../../BrainBar/Formatting/TextFormatter.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/HotkeyManager.swift b/brain-bar/Sources/BrainBarDaemon/HotkeyManager.swift new file mode 120000 index 00000000..f7f19134 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/HotkeyManager.swift @@ -0,0 +1 @@ +../BrainBar/HotkeyManager.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/HotkeyRouteStatus.swift b/brain-bar/Sources/BrainBarDaemon/HotkeyRouteStatus.swift new file mode 120000 index 00000000..f2521bd7 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/HotkeyRouteStatus.swift @@ -0,0 +1 @@ +../BrainBar/HotkeyRouteStatus.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/HybridSearchHelperClient.swift b/brain-bar/Sources/BrainBarDaemon/HybridSearchHelperClient.swift new file mode 120000 index 00000000..1a0ea872 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/HybridSearchHelperClient.swift @@ -0,0 +1 @@ +../BrainBar/HybridSearchHelperClient.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/InjectionEvent.swift b/brain-bar/Sources/BrainBarDaemon/InjectionEvent.swift new file mode 120000 index 00000000..4b1cfed8 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/InjectionEvent.swift @@ -0,0 +1 @@ +../BrainBar/InjectionEvent.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/InjectionFeedView.swift b/brain-bar/Sources/BrainBarDaemon/InjectionFeedView.swift new file mode 120000 index 00000000..17a00fa6 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/InjectionFeedView.swift @@ -0,0 +1 @@ +../BrainBar/InjectionFeedView.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/InjectionPresentation.swift b/brain-bar/Sources/BrainBarDaemon/InjectionPresentation.swift new file mode 120000 index 00000000..1c724ded --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/InjectionPresentation.swift @@ -0,0 +1 @@ +../BrainBar/InjectionPresentation.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/InjectionStore.swift b/brain-bar/Sources/BrainBarDaemon/InjectionStore.swift new file mode 120000 index 00000000..4e0d2d3a --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/InjectionStore.swift @@ -0,0 +1 @@ +../BrainBar/InjectionStore.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/InjectionSummaryView.swift b/brain-bar/Sources/BrainBarDaemon/InjectionSummaryView.swift new file mode 120000 index 00000000..e239e6eb --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/InjectionSummaryView.swift @@ -0,0 +1 @@ +../BrainBar/InjectionSummaryView.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/EntityCache.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/EntityCache.swift new file mode 120000 index 00000000..49fba994 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/EntityCache.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/EntityCache.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasLayout.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasLayout.swift new file mode 120000 index 00000000..a611831f --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasLayout.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/KGAtlasLayout.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasPresentation.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasPresentation.swift new file mode 120000 index 00000000..795c7e2e --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGAtlasPresentation.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/KGAtlasPresentation.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGCanvasView.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGCanvasView.swift new file mode 120000 index 00000000..0864aa58 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGCanvasView.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/KGCanvasView.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdge.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdge.swift new file mode 120000 index 00000000..7dd70b01 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdge.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/KGEdge.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdgeView.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdgeView.swift new file mode 120000 index 00000000..e9530be3 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGEdgeView.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/KGEdgeView.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNode.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNode.swift new file mode 120000 index 00000000..831adac6 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNode.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/KGNode.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNodeView.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNodeView.swift new file mode 120000 index 00000000..62d5fcfa --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGNodeView.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/KGNodeView.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSidebarView.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSidebarView.swift new file mode 120000 index 00000000..9b5485a0 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSidebarView.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/KGSidebarView.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSimulationController.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSimulationController.swift new file mode 120000 index 00000000..d2da7bd8 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGSimulationController.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/KGSimulationController.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGViewModel.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGViewModel.swift new file mode 120000 index 00000000..4cdeda6d --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/KGViewModel.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/KGViewModel.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/ScrollWheelZoomView.swift b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/ScrollWheelZoomView.swift new file mode 120000 index 00000000..bcad7b75 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/KnowledgeGraph/ScrollWheelZoomView.swift @@ -0,0 +1 @@ +../../BrainBar/KnowledgeGraph/ScrollWheelZoomView.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/MCPFraming.swift b/brain-bar/Sources/BrainBarDaemon/MCPFraming.swift new file mode 120000 index 00000000..ffb7d8b4 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/MCPFraming.swift @@ -0,0 +1 @@ +../BrainBar/MCPFraming.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/MCPRouter.swift b/brain-bar/Sources/BrainBarDaemon/MCPRouter.swift new file mode 120000 index 00000000..094f6399 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/MCPRouter.swift @@ -0,0 +1 @@ +../BrainBar/MCPRouter.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Models/DigestResult.swift b/brain-bar/Sources/BrainBarDaemon/Models/DigestResult.swift new file mode 120000 index 00000000..913d3b4e --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Models/DigestResult.swift @@ -0,0 +1 @@ +../../BrainBar/Models/DigestResult.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Models/EntityCard.swift b/brain-bar/Sources/BrainBarDaemon/Models/EntityCard.swift new file mode 120000 index 00000000..4291a315 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Models/EntityCard.swift @@ -0,0 +1 @@ +../../BrainBar/Models/EntityCard.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Models/KGSearchResult.swift b/brain-bar/Sources/BrainBarDaemon/Models/KGSearchResult.swift new file mode 120000 index 00000000..39a62939 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Models/KGSearchResult.swift @@ -0,0 +1 @@ +../../BrainBar/Models/KGSearchResult.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Models/SearchResult.swift b/brain-bar/Sources/BrainBarDaemon/Models/SearchResult.swift new file mode 120000 index 00000000..e9cc0d3b --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Models/SearchResult.swift @@ -0,0 +1 @@ +../../BrainBar/Models/SearchResult.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Models/StatsResult.swift b/brain-bar/Sources/BrainBarDaemon/Models/StatsResult.swift new file mode 120000 index 00000000..b0679c9d --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Models/StatsResult.swift @@ -0,0 +1 @@ +../../BrainBar/Models/StatsResult.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/QuickCaptureController.swift b/brain-bar/Sources/BrainBarDaemon/QuickCaptureController.swift new file mode 120000 index 00000000..64e241db --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/QuickCaptureController.swift @@ -0,0 +1 @@ +../BrainBar/QuickCaptureController.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/QuickCapturePanel.swift b/brain-bar/Sources/BrainBarDaemon/QuickCapturePanel.swift new file mode 120000 index 00000000..c6842360 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/QuickCapturePanel.swift @@ -0,0 +1 @@ +../BrainBar/QuickCapturePanel.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/QuickCapturePanelState.swift b/brain-bar/Sources/BrainBarDaemon/QuickCapturePanelState.swift new file mode 120000 index 00000000..add436df --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/QuickCapturePanelState.swift @@ -0,0 +1 @@ +../BrainBar/QuickCapturePanelState.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/SearchFilters.swift b/brain-bar/Sources/BrainBarDaemon/SearchFilters.swift new file mode 120000 index 00000000..91437902 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/SearchFilters.swift @@ -0,0 +1 @@ +../BrainBar/SearchFilters.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/SearchPanelController.swift b/brain-bar/Sources/BrainBarDaemon/SearchPanelController.swift new file mode 120000 index 00000000..9e274a81 --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/SearchPanelController.swift @@ -0,0 +1 @@ +../BrainBar/SearchPanelController.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/SearchQueryActor.swift b/brain-bar/Sources/BrainBarDaemon/SearchQueryActor.swift new file mode 120000 index 00000000..6a6d9e2c --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/SearchQueryActor.swift @@ -0,0 +1 @@ +../BrainBar/SearchQueryActor.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/SearchViewModel.swift b/brain-bar/Sources/BrainBarDaemon/SearchViewModel.swift new file mode 120000 index 00000000..d4cfe94e --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/SearchViewModel.swift @@ -0,0 +1 @@ +../BrainBar/SearchViewModel.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Views/Components/ChunkConversationSheet.swift b/brain-bar/Sources/BrainBarDaemon/Views/Components/ChunkConversationSheet.swift new file mode 120000 index 00000000..f7ad337e --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Views/Components/ChunkConversationSheet.swift @@ -0,0 +1 @@ +../../../BrainBar/Views/Components/ChunkConversationSheet.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultCard.swift b/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultCard.swift new file mode 120000 index 00000000..8130472c --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultCard.swift @@ -0,0 +1 @@ +../../../BrainBar/Views/Components/SearchResultCard.swift \ No newline at end of file diff --git a/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultsList.swift b/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultsList.swift new file mode 120000 index 00000000..e544978a --- /dev/null +++ b/brain-bar/Sources/BrainBarDaemon/Views/Components/SearchResultsList.swift @@ -0,0 +1 @@ +../../../BrainBar/Views/Components/SearchResultsList.swift \ No newline at end of file diff --git a/brain-bar/Tests/BrainBarTests/DatabaseTests.swift b/brain-bar/Tests/BrainBarTests/DatabaseTests.swift index 2c86db7f..180720eb 100644 --- a/brain-bar/Tests/BrainBarTests/DatabaseTests.swift +++ b/brain-bar/Tests/BrainBarTests/DatabaseTests.swift @@ -82,6 +82,17 @@ final class DatabaseTests: XCTestCase { XCTAssertTrue(exists, "brainbar_subscriptions table must exist") } + func testSchemaMigrationsTableIncludesDetailsForHybridHelperParity() throws { + let columns = try sqliteTableColumns(path: tempDBPath, table: "schema_migrations") + + XCTAssertTrue(columns.contains("name")) + XCTAssertTrue(columns.contains("applied_at")) + XCTAssertTrue( + columns.contains("details"), + "Swift startup must keep schema_migrations compatible with the Phase D Python helper" + ) + } + func testInjectionEventsTableExists() throws { let exists = try db.tableExists("injection_events") XCTAssertTrue(exists, "injection_events table must exist") @@ -1029,6 +1040,25 @@ private func sqliteQueryPlan(path: String, sql: String, binds: [String]) throws } } +private func sqliteTableColumns(path: String, table: String) throws -> Set { + try withSQLiteConnection(path: path) { db in + var stmt: OpaquePointer? + let rc = sqlite3_prepare_v2(db, "PRAGMA table_info(\(table))", -1, &stmt, nil) + guard rc == SQLITE_OK else { + throw NSError(domain: "DatabaseTests", code: Int(rc)) + } + defer { sqlite3_finalize(stmt) } + + var columns = Set() + while sqlite3_step(stmt) == SQLITE_ROW { + if let text = sqlite3_column_text(stmt, 1) { + columns.insert(String(cString: text)) + } + } + return columns + } +} + private func withSQLiteConnection(path: String, body: (OpaquePointer) throws -> T) throws -> T { var db: OpaquePointer? let rc = sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) diff --git a/brain-bar/build-app.sh b/brain-bar/build-app.sh index 7c17a007..68c22664 100755 --- a/brain-bar/build-app.sh +++ b/brain-bar/build-app.sh @@ -51,10 +51,14 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PACKAGE_DIR="$SCRIPT_DIR" BUNDLE_DIR="$SCRIPT_DIR/bundle" SIGN_IDENTITY="${BRAINBAR_CODESIGN_IDENTITY:-Apple Development: Etan Heyman (DXHB5E7P2D)}" -PLIST_LABEL="com.brainlayer.brainbar" -PLIST_FILENAME="$PLIST_LABEL.plist" -PLIST_SRC="$BUNDLE_DIR/$PLIST_FILENAME" -PLIST_DST="$HOME/Library/LaunchAgents/$PLIST_FILENAME" +UI_PLIST_LABEL="com.brainlayer.brainbar" +DAEMON_PLIST_LABEL="com.brainlayer.brainbar-daemon" +UI_PLIST_FILENAME="$UI_PLIST_LABEL.plist" +DAEMON_PLIST_FILENAME="$DAEMON_PLIST_LABEL.plist" +UI_PLIST_SRC="$BUNDLE_DIR/$UI_PLIST_FILENAME" +DAEMON_PLIST_SRC="$BUNDLE_DIR/$DAEMON_PLIST_FILENAME" +UI_PLIST_DST="$HOME/Library/LaunchAgents/$UI_PLIST_FILENAME" +DAEMON_PLIST_DST="$HOME/Library/LaunchAgents/$DAEMON_PLIST_FILENAME" LAUNCH_DOMAIN="gui/$(id -u)" SOCKET_PATH="${BRAINBAR_SOCKET_PATH:-/tmp/brainbar.sock}" PLIST_BUDDY="/usr/libexec/PlistBuddy" @@ -111,9 +115,10 @@ if [ "$DRY_RUN" -eq 1 ]; then echo "[build-app] Repo: $CURRENT_REPO_ROOT" echo "[build-app] App path: $APP_DIR" if [ "$DEV_BUNDLE_BUILD" -eq 1 ]; then - echo "[build-app] LaunchAgent: skipped for DEV worktree build" + echo "[build-app] LaunchAgents: skipped for DEV worktree build" else - echo "[build-app] LaunchAgent: canonical install to $PLIST_DST" + echo "[build-app] UI LaunchAgent: canonical install to $UI_PLIST_DST" + echo "[build-app] Daemon LaunchAgent: canonical install to $DAEMON_PLIST_DST" fi exit 0 fi @@ -170,9 +175,13 @@ stamp_info_plist() { } bootout_launchagent() { - launchctl bootout "$LAUNCH_DOMAIN/$PLIST_LABEL" 2>/dev/null || true - if [ -f "$PLIST_DST" ]; then - launchctl bootout "$LAUNCH_DOMAIN" "$PLIST_DST" 2>/dev/null || true + launchctl bootout "$LAUNCH_DOMAIN/$UI_PLIST_LABEL" 2>/dev/null || true + launchctl bootout "$LAUNCH_DOMAIN/$DAEMON_PLIST_LABEL" 2>/dev/null || true + if [ -f "$UI_PLIST_DST" ]; then + launchctl bootout "$LAUNCH_DOMAIN" "$UI_PLIST_DST" 2>/dev/null || true + fi + if [ -f "$DAEMON_PLIST_DST" ]; then + launchctl bootout "$LAUNCH_DOMAIN" "$DAEMON_PLIST_DST" 2>/dev/null || true fi } @@ -186,6 +195,16 @@ wait_for_brainbar_exit() { return 1 } +wait_for_brainbar_daemon_exit() { + for _ in $(seq 1 100); do + if ! pgrep -x BrainBarDaemon > /dev/null 2>&1; then + return 0 + fi + sleep 0.2 + done + return 1 +} + wait_for_socket() { local path="$1" local attempts="${BRAINBAR_SOCKET_WAIT_ATTEMPTS:-300}" @@ -199,9 +218,9 @@ wait_for_socket() { } if [ "$DEV_BUNDLE_BUILD" -eq 0 ]; then - # Stop LaunchAgent first so KeepAlive cannot race the rebuild and unlink the + # Stop LaunchAgents first so KeepAlive cannot race the rebuild and unlink the # freshly rebound socket from an older instance that is still terminating. - echo "[build-app] Stopping LaunchAgent..." + echo "[build-app] Stopping LaunchAgents..." bootout_launchagent # Kill any running BrainBar instances before installing. @@ -214,21 +233,36 @@ if [ "$DEV_BUNDLE_BUILD" -eq 0 ]; then exit 1 fi fi + if pgrep -x BrainBarDaemon > /dev/null 2>&1; then + echo "[build-app] Stopping running BrainBarDaemon instances..." + killall BrainBarDaemon 2>/dev/null || true + if ! wait_for_brainbar_daemon_exit; then + echo "[build-app] ERROR: BrainBarDaemon did not exit cleanly" + pgrep -fl BrainBarDaemon || true + exit 1 + fi + fi rm -f "$SOCKET_PATH" else echo "[build-app] DEV worktree build: preserving canonical LaunchAgent and socket" fi -echo "[build-app] Building BrainBar (release)..." -swift build -c release --package-path "$PACKAGE_DIR" +echo "[build-app] Building BrainBar and BrainBarDaemon (release)..." +swift build -c release --package-path "$PACKAGE_DIR" --product BrainBar +swift build -c release --package-path "$PACKAGE_DIR" --product BrainBarDaemon # Find the built binary BIN_DIR="$(swift build -c release --package-path "$PACKAGE_DIR" --show-bin-path)" BINARY="$BIN_DIR/BrainBar" +DAEMON_BINARY="$BIN_DIR/BrainBarDaemon" if [ ! -f "$BINARY" ]; then echo "[build-app] ERROR: Binary not found at $BINARY" exit 1 fi +if [ ! -f "$DAEMON_BINARY" ]; then + echo "[build-app] ERROR: Daemon binary not found at $DAEMON_BINARY" + exit 1 +fi # Clean stale bundle if [ -d "$APP_DIR" ]; then @@ -242,6 +276,7 @@ mkdir -p "$APP_DIR/Contents/Resources" cp "$BUNDLE_DIR/Info.plist" "$APP_DIR/Contents/" cp "$BINARY" "$APP_DIR/Contents/MacOS/BrainBar" +cp "$DAEMON_BINARY" "$APP_DIR/Contents/MacOS/BrainBarDaemon" COMMIT_SHA="$(git_commit)" DESCRIBE_REF="$(git_describe)" @@ -266,19 +301,31 @@ fi # Register URL scheme with Launch Services (ensures brainbar:// works after rebuild) /System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -R "$APP_DIR" -# Install LaunchAgent (expands path to actual APP_DIR) -if [ "$DEV_BUNDLE_BUILD" -eq 0 ] && [ -f "$PLIST_SRC" ]; then - echo "[build-app] Installing LaunchAgent to $PLIST_DST..." - bootout_launchagent +install_launchagent() { + local source_plist="$1" + local target_plist="$2" + local label="$3" + + echo "[build-app] Installing LaunchAgent to $target_plist..." TMP_PLIST="$(mktemp)" trap 'rm -f "$TMP_PLIST"' EXIT - sed "s|/Applications/BrainBar.app|$APP_DIR|g" "$PLIST_SRC" > "$TMP_PLIST" + sed "s|/Applications/BrainBar.app|$APP_DIR|g" "$source_plist" > "$TMP_PLIST" configure_launchagent_environment "$TMP_PLIST" "$CURRENT_REPO_ROOT" - mv "$TMP_PLIST" "$PLIST_DST" + mv "$TMP_PLIST" "$target_plist" trap - EXIT - launchctl bootstrap "$LAUNCH_DOMAIN" "$PLIST_DST" - launchctl kickstart -k "$LAUNCH_DOMAIN/$PLIST_LABEL" - echo "[build-app] LaunchAgent installed — BrainBar will auto-restart after quit" + launchctl bootstrap "$LAUNCH_DOMAIN" "$target_plist" + launchctl kickstart -k "$LAUNCH_DOMAIN/$label" +} + +# Install LaunchAgents (expands path to actual APP_DIR) +if [ "$DEV_BUNDLE_BUILD" -eq 0 ]; then + if [ -f "$DAEMON_PLIST_SRC" ]; then + install_launchagent "$DAEMON_PLIST_SRC" "$DAEMON_PLIST_DST" "$DAEMON_PLIST_LABEL" + fi + if [ -f "$UI_PLIST_SRC" ]; then + install_launchagent "$UI_PLIST_SRC" "$UI_PLIST_DST" "$UI_PLIST_LABEL" + fi + echo "[build-app] LaunchAgents installed — daemon and UI restart independently" fi if [ "$DEV_BUNDLE_BUILD" -eq 0 ]; then diff --git a/brain-bar/bundle/com.brainlayer.brainbar-daemon.plist b/brain-bar/bundle/com.brainlayer.brainbar-daemon.plist new file mode 100644 index 00000000..e7608d0b --- /dev/null +++ b/brain-bar/bundle/com.brainlayer.brainbar-daemon.plist @@ -0,0 +1,24 @@ + + + + + Label + com.brainlayer.brainbar-daemon + ProgramArguments + + /Applications/BrainBar.app/Contents/MacOS/BrainBarDaemon + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/brainbar-daemon.stdout.log + StandardErrorPath + /tmp/brainbar-daemon.stderr.log + ProcessType + Interactive + ThrottleInterval + 10 + + diff --git a/tests/test_brainbar_build_app_guards.py b/tests/test_brainbar_build_app_guards.py index 55194a7e..b6ba4c2c 100644 --- a/tests/test_brainbar_build_app_guards.py +++ b/tests/test_brainbar_build_app_guards.py @@ -108,7 +108,8 @@ def test_build_app_allows_clean_canonical_repo_in_dry_run(tmp_path: Path) -> Non assert result.returncode == 0 assert str(home / "Applications" / "BrainBar.app") in result.stdout - assert "LaunchAgent: canonical install" in result.stdout + assert "UI LaunchAgent: canonical install" in result.stdout + assert "Daemon LaunchAgent: canonical install" in result.stdout def test_build_app_helpers_ignore_parent_git_hook_env(tmp_path: Path, monkeypatch) -> None: @@ -174,7 +175,7 @@ def test_build_app_routes_forced_noncanonical_repo_to_dev_bundle(tmp_path: Path) assert result.returncode == 0 assert str(home / "Applications" / "BrainBar-DEV-feat-ui-guards.app") in result.stdout - assert "LaunchAgent: skipped for DEV worktree build" in result.stdout + assert "LaunchAgents: skipped for DEV worktree build" in result.stdout def test_build_app_rejects_dirty_canonical_repo_without_force(tmp_path: Path) -> None: @@ -306,3 +307,22 @@ def test_build_app_rejects_untracked_dirty_repo_even_when_status_hides_untracked assert result.returncode != 0 assert "dirty" in result.stderr.lower() assert "UNTRACKED.txt" in result.stderr + + +def test_brainbar_daemon_launchagent_runs_interactive_daemon_binary() -> None: + plist = Path(__file__).resolve().parents[1] / "brain-bar" / "bundle" / "com.brainlayer.brainbar-daemon.plist" + content = plist.read_text() + + assert "com.brainlayer.brainbar-daemon" in content + assert "/Applications/BrainBar.app/Contents/MacOS/BrainBarDaemon" in content + assert "ProcessType" in content + assert "Interactive" in content + + +def test_brainbar_package_declares_separate_ui_and_daemon_products() -> None: + package = Path(__file__).resolve().parents[1] / "brain-bar" / "Package.swift" + content = package.read_text() + + assert '.executable(name: "BrainBar", targets: ["BrainBar"])' in content + assert '.executable(name: "BrainBarDaemon", targets: ["BrainBarDaemon"])' in content + assert 'path: "Sources/BrainBarDaemon"' in content From 6e29ad916e3bb6b490f05bba9740d60a81291bd1 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 13:29:11 +0300 Subject: [PATCH 25/28] fix: refresh BrainBar stats before bus events --- .../BrainBar/Dashboard/StatsCollector.swift | 3 +-- .../Tests/BrainBarTests/DashboardTests.swift | 27 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift b/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift index df62b96d..d1208644 100644 --- a/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift +++ b/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift @@ -64,6 +64,7 @@ final class StatsCollector: ObservableObject { guard !isRunning else { return } isRunning = true installDarwinObserver() + refresh(force: true) if let brainBusEvents { let eventStream = brainBusEvents.events() brainBusTask = Task { [weak self] in @@ -74,8 +75,6 @@ final class StatsCollector: ObservableObject { } } } - } else { - refresh(force: true) } } diff --git a/brain-bar/Tests/BrainBarTests/DashboardTests.swift b/brain-bar/Tests/BrainBarTests/DashboardTests.swift index cfd2e4cf..28ccbff1 100644 --- a/brain-bar/Tests/BrainBarTests/DashboardTests.swift +++ b/brain-bar/Tests/BrainBarTests/DashboardTests.swift @@ -270,7 +270,32 @@ final class DashboardTests: XCTestCase { let elapsedMillis = Double(DispatchTime.now().uptimeNanoseconds - startedAt.uptimeNanoseconds) / 1_000_000 XCTAssertEqual(eventSource.streamRequestCount, 1) - XCTAssertLessThan(elapsedMillis, 100) + XCTAssertLessThan(elapsedMillis, 1_000) + } + + @MainActor + func testStatsCollectorRefreshesImmediatelyWithBrainBusEvents() throws { + try db.insertChunk( + id: "dash-preexisting", + content: "Inserted before collector start", + sessionId: "dashboard", + project: "brainlayer", + contentType: "assistant_text", + importance: 5 + ) + + let eventSource = RecordingBrainBusEventSource() + let collector = StatsCollector( + dbPath: tempDBPath, + daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + brainBusEvents: eventSource + ) + defer { collector.stop() } + + collector.start() + + XCTAssertEqual(eventSource.streamRequestCount, 1) + XCTAssertEqual(collector.stats.chunkCount, 1) } func testPipelineStateTreatsMissingDaemonSnapshotAsDegraded() { From 095bc5bc6d9d05a34135a666875f79a3e54e39d1 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 13:39:12 +0300 Subject: [PATCH 26/28] test: wait for BrainBar socket readiness --- .../BrainBarTests/SocketIntegrationTests.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index ecfb484c..03963134 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -19,8 +19,7 @@ final class SocketIntegrationTests: XCTestCase { db = BrainDatabase(path: tempDBPath) server = BrainBarServer(socketPath: testSocketPath, dbPath: tempDBPath, database: db) server.start() - // Give server time to bind - Thread.sleep(forTimeInterval: 0.2) + XCTAssertTrue(waitForSocket(at: testSocketPath), "Server should bind \(testSocketPath)") } override func tearDown() { @@ -268,7 +267,7 @@ final class SocketIntegrationTests: XCTestCase { ) server = BrainBarServer(socketPath: testSocketPath, dbPath: tempDBPath, database: db, hybridSearchClient: helper) server.start() - Thread.sleep(forTimeInterval: 0.2) + XCTAssertTrue(waitForSocket(at: testSocketPath), "Server should bind \(testSocketPath)") _ = try sendMCPRequest([ "jsonrpc": "2.0", "id": 1, "method": "initialize", @@ -506,7 +505,7 @@ final class SocketIntegrationTests: XCTestCase { flushDB.close() } server.start() - Thread.sleep(forTimeInterval: 0.2) + XCTAssertTrue(waitForSocket(at: testSocketPath), "Server should bind \(testSocketPath)") let subscriberFD = try connectClient() defer { close(subscriberFD) } @@ -875,6 +874,17 @@ final class SocketIntegrationTests: XCTestCase { // MARK: - Helper + private func waitForSocket(at path: String, timeout: TimeInterval = 3.0) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: path) { + return true + } + Thread.sleep(forTimeInterval: 0.01) + } + return FileManager.default.fileExists(atPath: path) + } + private func sendMCPRequest(_ request: [String: Any]) throws -> [String: Any] { let fd = try connectClient() defer { close(fd) } From 47d829ac21692132b6e49eeee45804c27c5f01d1 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Mon, 18 May 2026 13:51:44 +0300 Subject: [PATCH 27/28] test: relax arbitration drain deadline --- tests/test_arbitration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_arbitration.py b/tests/test_arbitration.py index 155f495d..913df96d 100644 --- a/tests/test_arbitration.py +++ b/tests/test_arbitration.py @@ -152,7 +152,7 @@ def test_drain_daemon_serializes_three_concurrent_producers(tmp_path, monkeypatc worker.join(timeout=20) assert worker.exitcode == 0 - deadline = time.monotonic() + 15 + deadline = time.monotonic() + 45 total_drained = 0 while time.monotonic() < deadline: total_drained += drain_once(db_path=db_path, queue_dir=queue_dir, batch_size=250, log_path=log_path) From 931fa936d3f337d66f831d60c3f509fb04f5eae3 Mon Sep 17 00:00:00 2001 From: Etan Heyman <93338119+EtanHey@users.noreply.github.com> Date: Mon, 18 May 2026 13:59:52 +0300 Subject: [PATCH 28/28] fix: open BrainBar UI stats database read-only (#300) --- brain-bar/Sources/BrainBar/BrainBarApp.swift | 9 +++-- .../Sources/BrainBar/BrainDatabase.swift | 35 ++++++++++++++++--- .../BrainBar/Dashboard/StatsCollector.swift | 6 ++-- .../Tests/BrainBarTests/DashboardTests.swift | 33 +++++++++++++++++ .../Tests/BrainBarTests/DatabaseTests.swift | 30 ++++++++++++++++ 5 files changed, 103 insertions(+), 10 deletions(-) diff --git a/brain-bar/Sources/BrainBar/BrainBarApp.swift b/brain-bar/Sources/BrainBar/BrainBarApp.swift index dc223ee0..ebd2b94d 100644 --- a/brain-bar/Sources/BrainBar/BrainBarApp.swift +++ b/brain-bar/Sources/BrainBar/BrainBarApp.swift @@ -12,12 +12,14 @@ enum BrainBarAppSupport { static func makeStatsCollector( dbPath: String, targetPID: pid_t, - brainBusEvents: BrainBusEventSource? = BrainBusClient() + brainBusEvents: BrainBusEventSource? = BrainBusClient(), + databaseOpenConfiguration: BrainDatabase.OpenConfiguration = BrainDatabase.OpenConfiguration() ) -> StatsCollector { StatsCollector( dbPath: dbPath, daemonMonitor: DaemonHealthMonitor(targetPID: targetPID), - brainBusEvents: brainBusEvents + brainBusEvents: brainBusEvents, + databaseOpenConfiguration: databaseOpenConfiguration ) } @@ -29,7 +31,8 @@ enum BrainBarAppSupport { makeStatsCollector( dbPath: dbPath, targetPID: 0, - brainBusEvents: brainBusEvents + brainBusEvents: brainBusEvents, + databaseOpenConfiguration: BrainDatabase.OpenConfiguration(readOnly: true) ) } } diff --git a/brain-bar/Sources/BrainBar/BrainDatabase.swift b/brain-bar/Sources/BrainBar/BrainDatabase.swift index 9bb91ea8..c4ba4c43 100644 --- a/brain-bar/Sources/BrainBar/BrainDatabase.swift +++ b/brain-bar/Sources/BrainBar/BrainDatabase.swift @@ -10,9 +10,11 @@ import SQLite3 final class BrainDatabase: @unchecked Sendable { struct OpenConfiguration: Sendable, Equatable { let busyTimeoutMillis: Int32 + let readOnly: Bool - init(busyTimeoutMillis: Int32 = 30_000) { + init(busyTimeoutMillis: Int32 = 30_000, readOnly: Bool = false) { self.busyTimeoutMillis = max(1, busyTimeoutMillis) + self.readOnly = readOnly } } @@ -255,6 +257,12 @@ final class BrainDatabase: @unchecked Sendable { NSLog("[BrainBar] Connection opened, configuring...") try configureConnection(db) NSLog("[BrainBar] Connection configured, checking schema...") + if openConfiguration.readOnly { + NSLog("[BrainBar] Read-only connection - skipping schema migration checks") + lastOpenError = nil + isOpen = true + return + } // AIDEV-NOTE: Skip ensureSchema if chunks table already exists (Python creates all tables). // CREATE TABLE IF NOT EXISTS still acquires a RESERVED lock which blocks on watch agent. if (try? tableExists("chunks")) == true { @@ -268,11 +276,21 @@ final class BrainDatabase: @unchecked Sendable { isOpen = true NSLog("[BrainBar] Database ready") } catch { + if let db { + sqlite3_close(db) + self.db = nil + } + isOpen = false lastOpenError = error NSLog("[BrainBar] Failed to open/configure database at %@: %@", path, String(describing: error)) } } + func reopenIfNeeded() { + guard !isOpen else { return } + openAndConfigure() + } + // tableExists defined at line ~237 (existing method) private func ensureSchema() throws { @@ -467,6 +485,7 @@ final class BrainDatabase: @unchecked Sendable { sqlite3_close(db) self.db = nil } + isOpen = false } func vacuumInto(targetPath: String) throws -> Int64 { @@ -1662,12 +1681,12 @@ final class BrainDatabase: @unchecked Sendable { private func openConnection(path: String) throws -> OpaquePointer { var handle: OpaquePointer? - // AIDEV-NOTE: READWRITE is needed for save/search. The async init in BrainBarApp.swift - // ensures this runs on a background queue so it doesn't block the menu item. - let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX + let flags = openConfiguration.readOnly + ? SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX + : SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX let rc = sqlite3_open_v2(path, &handle, flags, nil) guard rc == SQLITE_OK, let handle else { throw DBError.open(path, rc) } - NSLog("[BrainBar] Database opened READWRITE") + NSLog("[BrainBar] Database opened %@", openConfiguration.readOnly ? "READONLY" : "READWRITE") return handle } @@ -1675,6 +1694,12 @@ final class BrainDatabase: @unchecked Sendable { guard let handle else { throw DBError.notOpen } // AIDEV-NOTE: busy_timeout FIRST — 30s because watch agent holds locks during enrichment. try executeOnHandle(handle, sql: "PRAGMA busy_timeout = \(openConfiguration.busyTimeoutMillis)") + if openConfiguration.readOnly { + try executeOnHandle(handle, sql: "PRAGMA query_only = ON") + try executeOnHandle(handle, sql: "PRAGMA cache_size = -64000") + try executeOnHandle(handle, sql: "PRAGMA foreign_keys = ON") + return + } // AIDEV-NOTE: Skip journal_mode=WAL if already set — the PRAGMA itself needs a write lock // which blocks indefinitely when the watch agent is active. WAL is already set by Python. let currentMode = queryPragma(handle, name: "journal_mode") diff --git a/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift b/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift index d1208644..4ccef484 100644 --- a/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift +++ b/brain-bar/Sources/BrainBar/Dashboard/StatsCollector.swift @@ -38,9 +38,10 @@ final class StatsCollector: ObservableObject { dbPath: String, daemonMonitor: DaemonHealthMonitor, agentActivityMonitor: AgentActivityMonitor = AgentActivityMonitor(), - brainBusEvents: BrainBusEventSource? = nil + brainBusEvents: BrainBusEventSource? = nil, + databaseOpenConfiguration: BrainDatabase.OpenConfiguration = BrainDatabase.OpenConfiguration() ) { - self.database = BrainDatabase(path: dbPath) + self.database = BrainDatabase(path: dbPath, openConfiguration: databaseOpenConfiguration) self.daemonMonitor = daemonMonitor self.agentActivityMonitor = agentActivityMonitor self.brainBusEvents = brainBusEvents @@ -93,6 +94,7 @@ final class StatsCollector: ObservableObject { agentActivity = agentActivityMonitor.sample() do { + database.reopenIfNeeded() let currentDataVersion = try database.dataVersion() if force || currentDataVersion != lastDataVersion { stats = try database.dashboardStats( diff --git a/brain-bar/Tests/BrainBarTests/DashboardTests.swift b/brain-bar/Tests/BrainBarTests/DashboardTests.swift index 28ccbff1..6a20c8b4 100644 --- a/brain-bar/Tests/BrainBarTests/DashboardTests.swift +++ b/brain-bar/Tests/BrainBarTests/DashboardTests.swift @@ -255,6 +255,39 @@ final class DashboardTests: XCTestCase { XCTAssertEqual(collector.stats.chunkCount, 1) } + @MainActor + func testReadOnlyStatsCollectorReopensAfterDaemonCreatesDatabase() throws { + db.close() + try? FileManager.default.removeItem(atPath: tempDBPath) + try? FileManager.default.removeItem(atPath: tempDBPath + "-wal") + try? FileManager.default.removeItem(atPath: tempDBPath + "-shm") + + let collector = StatsCollector( + dbPath: tempDBPath, + daemonMonitor: DaemonHealthMonitor(targetPID: ProcessInfo.processInfo.processIdentifier), + databaseOpenConfiguration: BrainDatabase.OpenConfiguration(readOnly: true) + ) + defer { collector.stop() } + + collector.refresh(force: true) + XCTAssertEqual(collector.stats.chunkCount, 0) + + let writer = BrainDatabase(path: tempDBPath) + defer { writer.close() } + try writer.insertChunk( + id: "dash-readonly-late-db", + content: "Daemon created the database after UI startup", + sessionId: "dashboard", + project: "brainlayer", + contentType: "assistant_text", + importance: 5 + ) + + collector.refresh(force: true) + + XCTAssertEqual(collector.stats.chunkCount, 1) + } + @MainActor func testStatsCollectorSubscribesToBrainBusWithoutPollingDelay() { let eventSource = RecordingBrainBusEventSource() diff --git a/brain-bar/Tests/BrainBarTests/DatabaseTests.swift b/brain-bar/Tests/BrainBarTests/DatabaseTests.swift index 180720eb..122473d8 100644 --- a/brain-bar/Tests/BrainBarTests/DatabaseTests.swift +++ b/brain-bar/Tests/BrainBarTests/DatabaseTests.swift @@ -93,6 +93,36 @@ final class DatabaseTests: XCTestCase { ) } + func testReadOnlyOpenConfigurationAllowsDashboardReadsButRejectsWrites() throws { + try db.insertChunk( + id: "readonly-seed", + content: "Seed row for read-only dashboard stats", + sessionId: "readonly", + project: "brainlayer", + contentType: "assistant_text", + importance: 5 + ) + + let reader = BrainDatabase( + path: tempDBPath, + openConfiguration: .init(readOnly: true) + ) + defer { reader.close() } + + let stats = try reader.dashboardStats(activityWindowMinutes: 30, bucketCount: 6) + XCTAssertEqual(stats.chunkCount, 1) + XCTAssertThrowsError( + try reader.insertChunk( + id: "readonly-write", + content: "This write must not land from the UI process", + sessionId: "readonly", + project: "brainlayer", + contentType: "assistant_text", + importance: 5 + ) + ) + } + func testInjectionEventsTableExists() throws { let exists = try db.tableExists("injection_events") XCTAssertTrue(exists, "injection_events table must exist")