diff --git a/brain-bar/Sources/BrainBar/BrainDatabase.swift b/brain-bar/Sources/BrainBar/BrainDatabase.swift index 4c1cc53..22e8582 100644 --- a/brain-bar/Sources/BrainBar/BrainDatabase.swift +++ b/brain-bar/Sources/BrainBar/BrainDatabase.swift @@ -469,6 +469,40 @@ final class BrainDatabase: @unchecked Sendable { } } + func vacuumInto(targetPath: String) throws -> Int64 { + guard let db else { throw DBError.notOpen } + let trimmedTarget = targetPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTarget.isEmpty else { + throw DBError.exec(SQLITE_MISUSE, "target_path is required") + } + + let targetURL = URL(fileURLWithPath: trimmedTarget).standardizedFileURL + let sourceURL = URL(fileURLWithPath: path).standardizedFileURL + guard targetURL.path != sourceURL.path else { + throw DBError.exec(SQLITE_MISUSE, "refusing to VACUUM INTO the live database path") + } + + try FileManager.default.createDirectory( + at: targetURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + guard !FileManager.default.fileExists(atPath: targetURL.path) else { + throw DBError.exec(SQLITE_CANTOPEN, "backup target already exists: \(targetURL.path)") + } + + var stmt: OpaquePointer? + let prepareRC = sqlite3_prepare_v2(db, "VACUUM INTO ?", -1, &stmt, nil) + guard prepareRC == SQLITE_OK, let stmt else { throw DBError.prepare(prepareRC) } + defer { sqlite3_finalize(stmt) } + + bindText(targetURL.path, to: stmt, index: 1) + let stepRC = sqlite3_step(stmt) + guard stepRC == SQLITE_DONE else { throw DBError.step(stepRC) } + + let attributes = try FileManager.default.attributesOfItem(atPath: targetURL.path) + return (attributes[.size] as? NSNumber)?.int64Value ?? 0 + } + private static let allowedPragmas: Set = [ "journal_mode", "busy_timeout", "cache_size", "synchronous", "wal_checkpoint", "page_count", "page_size", "freelist_count" diff --git a/brain-bar/Sources/BrainBar/MCPRouter.swift b/brain-bar/Sources/BrainBar/MCPRouter.swift index 208b3a9..d38c58e 100644 --- a/brain-bar/Sources/BrainBar/MCPRouter.swift +++ b/brain-bar/Sources/BrainBar/MCPRouter.swift @@ -20,6 +20,18 @@ final class MCPRouter: @unchecked Sendable { } } + private struct BackupVacuumResult: Encodable { + let status: String + let targetPath: String + let bytes: Int64 + + enum CodingKeys: String, CodingKey { + case status + case targetPath = "target_path" + case bytes + } + } + private var database: BrainDatabase? let entityCache = EntityCache() private static let defaultStringMaxLength = 256 @@ -39,7 +51,8 @@ final class MCPRouter: @unchecked Sendable { "reason": 1_024, "session_id": 128, "source": 32, - "tag": 128 + "tag": 128, + "target_path": 4_096 ] private static let stringArrayLimits: [String: (maxItems: Int, itemMaxLength: Int)] = [ "chunk_ids": (maxItems: 500, itemMaxLength: 128), @@ -208,6 +221,8 @@ final class MCPRouter: @unchecked Sendable { return try handleBrainAck(arguments) case "brain_maintenance_rebuild_trigram": return try handleBrainMaintenanceRebuildTrigram(arguments) + case "brain_backup_vacuum_into": + return try handleBrainBackupVacuumInto(arguments) default: throw ToolError.unknownTool(name) } @@ -625,6 +640,22 @@ final class MCPRouter: @unchecked Sendable { ) } + private func handleBrainBackupVacuumInto(_ args: [String: Any]) throws -> ToolOutput { + guard let targetPath = args["target_path"] as? String else { + throw ToolError.missingParameter("target_path") + } + guard let db = database else { throw ToolError.noDatabase } + let bytes = try db.vacuumInto(targetPath: targetPath) + let payload = BackupVacuumResult(status: "ok", targetPath: targetPath, bytes: bytes) + return ToolOutput( + text: jsonEncode(payload), + metadata: [ + "target_path": targetPath, + "bytes": bytes, + ] + ) + } + /// Safe JSON encoding — never use string interpolation with user data. private func jsonEncode(_ value: T) -> String { guard let data = try? JSONEncoder().encode(value), @@ -1023,6 +1054,18 @@ final class MCPRouter: @unchecked Sendable { "required": ["agent_id", "seq"] ] as [String: Any]) ], + [ + "name": "brain_backup_vacuum_into", + "description": "Create a SQLite backup snapshot using VACUUM INTO on BrainBar's single-writer connection.", + "annotations": MCPRouter.writeIdempotentAnnotations, + "inputSchema": MCPRouter.limitedInputSchema([ + "type": "object", + "properties": [ + "target_path": ["type": "string", "description": "Absolute path for the new SQLite backup file"], + ] as [String: Any], + "required": ["target_path"] + ] as [String: Any]) + ], [ "name": "brain_maintenance_rebuild_trigram", "description": "Operator-triggered maintenance command to rebuild the trigram FTS table in lock-aware batches.", diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index 6032764..847fcdd 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -127,7 +127,7 @@ final class MCPRouterTests: XCTestCase { let tools = result?["tools"] as? [[String: Any]] XCTAssertNotNil(tools) - XCTAssertEqual(tools?.count, 16, "Should have exactly 16 tools") + XCTAssertEqual(tools?.count, 17, "Should have exactly 17 tools") let toolNames = Set(tools?.compactMap { $0["name"] as? String } ?? []) let expected: Set = [ @@ -135,7 +135,7 @@ final class MCPRouterTests: XCTestCase { "brain_digest", "brain_update", "brain_expand", "brain_tags", "brain_subscribe", "brain_unsubscribe", "brain_ack", "brain_get_person", "brain_supersede", "brain_archive", "brain_enrich", - "brain_maintenance_rebuild_trigram", + "brain_backup_vacuum_into", "brain_maintenance_rebuild_trigram", ] XCTAssertEqual(toolNames, expected) } @@ -168,7 +168,7 @@ final class MCPRouterTests: XCTestCase { let tools = (response["result"] as? [String: Any])?["tools"] as? [[String: Any]] ?? [] - XCTAssertEqual(tools.count, 16) + XCTAssertEqual(tools.count, 17) for tool in tools { XCTAssertNotNil( tool["annotations"], @@ -226,6 +226,7 @@ final class MCPRouterTests: XCTestCase { "brain_subscribe": (false, false, false, false), "brain_unsubscribe": (false, false, true, false), "brain_ack": (false, false, true, false), + "brain_backup_vacuum_into": (false, false, true, false), "brain_maintenance_rebuild_trigram": (false, false, true, false), ] diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index 2a28175..d6ea899 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -3,6 +3,7 @@ // Tests the full pipeline: connect to Unix socket → send Content-Length framed // MCP request → receive Content-Length framed response. +import SQLite3 import XCTest @testable import BrainBar @@ -91,7 +92,7 @@ final class SocketIntegrationTests: XCTestCase { let tools = (response["result"] as? [String: Any])?["tools"] as? [[String: Any]] XCTAssertNotNil(tools) - XCTAssertEqual(tools?.count, 16) + XCTAssertEqual(tools?.count, 17) let encodedResponse = try MCPFraming.encodeJSONResponse(response) XCTAssertGreaterThan(encodedResponse.count, 8192) @@ -135,7 +136,7 @@ final class SocketIntegrationTests: XCTestCase { 8192, "Claude Desktop's MCPB utility process parses raw extension stdout in 8192-byte chunks" ) - XCTAssertEqual(tools?.count, 16) + XCTAssertEqual(tools?.count, 17) for tool in tools ?? [] { XCTAssertNil( tool["annotations"], @@ -168,6 +169,53 @@ final class SocketIntegrationTests: XCTestCase { XCTAssertNotNil(response["result"]) } + func testBrainBackupVacuumIntoOverSocketCreatesRestorableSnapshot() throws { + let targetPath = NSTemporaryDirectory() + "brainbar-backup-\(UUID().uuidString).db" + defer { try? FileManager.default.removeItem(atPath: targetPath) } + + _ = try sendMCPRequest([ + "jsonrpc": "2.0", "id": 20, "method": "initialize", + "params": ["protocolVersion": "2024-11-05", "capabilities": [:] as [String: Any], + "clientInfo": ["name": "backup-test", "version": "1.0"]] + ]) + _ = try sendMCPRequest([ + "jsonrpc": "2.0", + "id": 21, + "method": "tools/call", + "params": [ + "name": "brain_store", + "arguments": [ + "content": "vacuum over socket", + "tags": ["backup-test"] + ] as [String: Any] + ] + ]) + + let response = try sendMCPRequest([ + "jsonrpc": "2.0", + "id": 22, + "method": "tools/call", + "params": [ + "name": "brain_backup_vacuum_into", + "arguments": ["target_path": targetPath] + ] + ]) + + XCTAssertNil(response["error"]) + let result = response["result"] as? [String: Any] + XCTAssertEqual(result?["isError"] as? Bool, nil) + XCTAssertTrue(FileManager.default.fileExists(atPath: targetPath)) + + var restored: OpaquePointer? + XCTAssertEqual(sqlite3_open_v2(targetPath, &restored, SQLITE_OPEN_READONLY, nil), SQLITE_OK) + defer { sqlite3_close(restored) } + XCTAssertEqual(try queryString("PRAGMA integrity_check", on: restored), "ok") + XCTAssertEqual( + try queryString("SELECT content FROM chunks WHERE content = 'vacuum over socket'", on: restored), + "vacuum over socket" + ) + } + func testMCPBrainSubscribeOverSocketReturnsCursorState() throws { _ = try sendMCPRequest([ "jsonrpc": "2.0", "id": 1, "method": "initialize", @@ -705,7 +753,7 @@ final class SocketIntegrationTests: XCTestCase { let toolsResponse = try JSONSerialization.jsonObject(with: Data(outputLines[1].utf8)) as? [String: Any] let tools = (toolsResponse?["result"] as? [String: Any])?["tools"] as? [[String: Any]] - XCTAssertEqual(tools?.count, 16) + XCTAssertEqual(tools?.count, 17) } // MARK: - C2: Socket path length validation @@ -741,6 +789,18 @@ final class SocketIntegrationTests: XCTestCase { return try readMCPMessage(fd: fd) } + private func queryString(_ sql: String, on db: OpaquePointer?) throws -> String? { + var stmt: OpaquePointer? + let rc = sqlite3_prepare_v2(db, sql, -1, &stmt, nil) + guard rc == SQLITE_OK else { + throw NSError(domain: "sqlite", code: Int(rc), userInfo: [NSLocalizedDescriptionKey: "prepare failed \(rc)"]) + } + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } + guard let value = sqlite3_column_text(stmt, 0) else { return nil } + return String(cString: value) + } + private func connectClient() throws -> Int32 { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "socket() failed"]) } diff --git a/scripts/launchd/backup-daily.sh b/scripts/launchd/backup-daily.sh index 5d06d51..f77cc87 100755 --- a/scripts/launchd/backup-daily.sh +++ b/scripts/launchd/backup-daily.sh @@ -3,6 +3,8 @@ set -euo pipefail export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin" export PYTHONUNBUFFERED=1 +: "${BRAINLAYER_BACKUP_TIMEOUT_SECONDS:=300}" +export BRAINLAYER_BACKUP_TIMEOUT_SECONDS BRAINLAYER_DIR="${BRAINLAYER_DIR:-__BRAINLAYER_DIR_VALUE__}" case "$BRAINLAYER_DIR" in __BRAINLAYER_DIR_*) BRAINLAYER_DIR="$HOME/Gits/brainlayer" ;; diff --git a/scripts/launchd/com.brainlayer.backup-daily.plist b/scripts/launchd/com.brainlayer.backup-daily.plist index 3f36443..9a63dce 100644 --- a/scripts/launchd/com.brainlayer.backup-daily.plist +++ b/scripts/launchd/com.brainlayer.backup-daily.plist @@ -37,6 +37,9 @@ Nice 15 + ExitTimeOut + 300 + ProcessType Background diff --git a/src/brainlayer/backup_daily.py b/src/brainlayer/backup_daily.py index 98afb66..c3eb7e3 100644 --- a/src/brainlayer/backup_daily.py +++ b/src/brainlayer/backup_daily.py @@ -13,6 +13,8 @@ import json import os import shutil +import signal +import socket import sqlite3 import tempfile import time @@ -28,6 +30,8 @@ DEFAULT_CLIENT_PATH = Path.home() / ".config" / "google-drive-mcp" / "gcp-oauth.keys.json" DEFAULT_FOLDER_PARTS = ["Brain Drive", "06_ARCHIVE", "backups", "brainlayer-db"] DEFAULT_STAGING_DIR = Path.home() / ".local" / "share" / "brainlayer" / "backups" +DEFAULT_BRAINBAR_SOCKET_PATH = "/tmp/brainbar.sock" +BACKUP_TIMEOUT_ENV = "BRAINLAYER_BACKUP_TIMEOUT_SECONDS" DRIVE_FOLDER_MIME = "application/vnd.google-apps.folder" DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive"] @@ -40,8 +44,32 @@ def _escape_drive_query_value(value: str) -> str: return value.replace("\\", "\\\\").replace("'", "\\'") -def create_sqlite_backup_gzip(db_path: Path, output_dir: Path, date_stamp: str | None = None) -> Path: - """Create a restorable `.db.gz` snapshot using SQLite's online backup API.""" +class BackupTimeoutError(TimeoutError): + pass + + +def _configured_backup_timeout_seconds() -> int | None: + raw = os.environ.get(BACKUP_TIMEOUT_ENV) + if raw is None or raw.strip() == "": + return None + try: + seconds = int(raw) + except ValueError as exc: + raise ValueError(f"{BACKUP_TIMEOUT_ENV} must be an integer number of seconds") from exc + return seconds if seconds > 0 else None + + +def _raise_backup_timeout(signum, frame) -> None: # noqa: ARG001 + raise BackupTimeoutError("backup exceeded configured wall-clock timeout") + + +def create_sqlite_backup_gzip( + db_path: Path, + output_dir: Path, + date_stamp: str | None = None, + socket_path: Path | str | None = None, +) -> Path: + """Create a restorable `.db.gz` snapshot through BrainBar's single-writer socket.""" db_path = Path(db_path).expanduser() output_dir = Path(output_dir).expanduser() date_stamp = date_stamp or _today() @@ -61,17 +89,14 @@ def create_sqlite_backup_gzip(db_path: Path, output_dir: Path, date_stamp: str | with tempfile.TemporaryDirectory(prefix="brainlayer-backup-", dir=output_dir) as tmp: raw_snapshot = Path(tmp) / f"{date_stamp}.db" - source = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=60) - target = sqlite3.connect(raw_snapshot) + request_brainbar_vacuum_into(raw_snapshot, socket_path=socket_path) + target = sqlite3.connect(f"file:{raw_snapshot}?mode=ro", uri=True) try: - source.backup(target, pages=10_000, sleep=0.1) - target.execute("PRAGMA wal_checkpoint(TRUNCATE)") integrity = target.execute("PRAGMA integrity_check").fetchone() if not integrity or integrity[0] != "ok": raise RuntimeError(f"Backup integrity check failed: {integrity!r}") finally: target.close() - source.close() temp_gz = Path(tmp) / final_gz.name with raw_snapshot.open("rb") as src, gzip.open(temp_gz, "wb", compresslevel=6) as dst: @@ -81,6 +106,54 @@ def create_sqlite_backup_gzip(db_path: Path, output_dir: Path, date_stamp: str | return final_gz +def _brainbar_socket_path(socket_path: Path | str | None = None) -> Path: + if socket_path is not None: + return Path(socket_path).expanduser() + return Path(os.environ.get("BRAINBAR_SOCKET_PATH", DEFAULT_BRAINBAR_SOCKET_PATH)).expanduser() + + +def request_brainbar_vacuum_into( + target_path: Path, socket_path: Path | str | None = None, timeout_seconds: int = 300 +) -> None: + target_path = Path(target_path).expanduser() + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "brain_backup_vacuum_into", + "arguments": {"target_path": str(target_path)}, + }, + } + response = _send_brainbar_json_request(_brainbar_socket_path(socket_path), request, timeout_seconds=timeout_seconds) + if response.get("error"): + raise RuntimeError(f"BrainBar backup request failed: {response['error']}") + result = response.get("result") or {} + if result.get("isError"): + content = result.get("content") or [] + text = content[0].get("text") if content and isinstance(content[0], dict) else result + raise RuntimeError(f"BrainBar backup request failed: {text}") + if not target_path.exists(): + raise RuntimeError(f"BrainBar backup did not create snapshot: {target_path}") + + +def _send_brainbar_json_request(socket_path: Path, request: dict[str, Any], timeout_seconds: int) -> dict[str, Any]: + payload = json.dumps(request, separators=(",", ":")).encode("utf-8") + b"\n" + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(timeout_seconds) + client.connect(str(socket_path)) + client.sendall(payload) + data = b"" + while not data.endswith(b"\n"): + chunk = client.recv(65_536) + if not chunk: + break + data += chunk + if not data: + raise RuntimeError(f"BrainBar socket closed without response: {socket_path}") + return json.loads(data.decode("utf-8")) + + def _atomic_write_text(path: Path, content: str) -> None: temp_path = path.with_name(f".{path.name}.{os.getpid()}.tmp") try: @@ -335,6 +408,12 @@ def run_backup( def main() -> int: + timeout_seconds = _configured_backup_timeout_seconds() + previous_alarm_handler = None + if timeout_seconds is not None: + previous_alarm_handler = signal.getsignal(signal.SIGALRM) + signal.signal(signal.SIGALRM, _raise_backup_timeout) + signal.setitimer(signal.ITIMER_REAL, timeout_seconds) try: result = run_backup( staging_dir=Path(os.environ.get("BRAINLAYER_BACKUP_STAGING_DIR", str(DEFAULT_STAGING_DIR))), @@ -344,9 +423,16 @@ def main() -> int: os.environ.get("BRAINLAYER_BACKUP_DRIVE_PATH", "/".join(DEFAULT_FOLDER_PARTS)), ).split("/"), ) + except BackupTimeoutError: + print(f"brainlayer backup timed out after {timeout_seconds}s", flush=True) + return 124 except Exception as exc: print(f"brainlayer backup failed: {exc}\n{traceback.format_exc()}", flush=True) return 1 + finally: + if timeout_seconds is not None: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous_alarm_handler) print(json.dumps(result, sort_keys=True), flush=True) return 0 diff --git a/tests/test_backup_daily.py b/tests/test_backup_daily.py index fbd059d..54c8e51 100644 --- a/tests/test_backup_daily.py +++ b/tests/test_backup_daily.py @@ -1,15 +1,58 @@ import gzip +import json +import os +import queue +import socket import sqlite3 +import threading +import time +import uuid from pathlib import Path import pytest -def test_create_snapshot_gzip_is_restorable(tmp_path): - from brainlayer.backup_daily import create_sqlite_backup_gzip - - source = tmp_path / "brainlayer.db" - conn = sqlite3.connect(source) +def _start_fake_brainbar_vacuum_server(socket_path: Path, source_db: Path): + received: queue.Queue[dict] = queue.Queue() + ready = threading.Event() + + def run() -> None: + if socket_path.exists(): + socket_path.unlink() + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + server.bind(str(socket_path)) + server.listen(1) + ready.set() + conn, _ = server.accept() + with conn: + data = b"" + while not data.endswith(b"\n"): + data += conn.recv(65_536) + request = json.loads(data.decode("utf-8")) + received.put(request) + args = request["params"]["arguments"] + target_path = Path(args["target_path"]) + with sqlite3.connect(source_db) as db: + db.execute("VACUUM INTO ?", (str(target_path),)) + response = { + "jsonrpc": "2.0", + "id": request["id"], + "result": { + "content": [ + {"type": "text", "text": json.dumps({"status": "ok", "target_path": str(target_path)})} + ] + }, + } + conn.sendall(json.dumps(response).encode("utf-8") + b"\n") + + thread = threading.Thread(target=run, daemon=True) + thread.start() + assert ready.wait(timeout=2) + return received, thread + + +def _create_source_db(path: Path) -> None: + conn = sqlite3.connect(path) journal_mode = conn.execute("PRAGMA journal_mode=WAL").fetchone()[0] assert journal_mode.upper() == "WAL" conn.execute("CREATE TABLE chunks (id TEXT PRIMARY KEY, content TEXT)") @@ -17,8 +60,17 @@ def test_create_snapshot_gzip_is_restorable(tmp_path): conn.commit() conn.close() + +def test_create_snapshot_gzip_is_restorable(tmp_path): + from brainlayer.backup_daily import create_sqlite_backup_gzip + + source = tmp_path / "brainlayer.db" + _create_source_db(source) + socket_path = Path(f"/tmp/bb-{os.getpid()}-{uuid.uuid4().hex}.sock") + _start_fake_brainbar_vacuum_server(socket_path, source) + out_dir = tmp_path / "out" - snapshot = create_sqlite_backup_gzip(source, out_dir, date_stamp="2026-05-13") + snapshot = create_sqlite_backup_gzip(source, out_dir, date_stamp="2026-05-13", socket_path=socket_path) assert snapshot == out_dir / "2026-05-13.db.gz" assert snapshot.exists() @@ -35,6 +87,24 @@ def test_create_snapshot_gzip_is_restorable(tmp_path): restored_conn.close() +def test_create_snapshot_routes_vacuum_into_over_brainbar_socket(tmp_path): + from brainlayer.backup_daily import create_sqlite_backup_gzip + + source = tmp_path / "brainlayer.db" + _create_source_db(source) + socket_path = Path(f"/tmp/bb-{os.getpid()}-{uuid.uuid4().hex}.sock") + received, thread = _start_fake_brainbar_vacuum_server(socket_path, source) + + snapshot = create_sqlite_backup_gzip(source, tmp_path / "out", date_stamp="2026-05-13", socket_path=socket_path) + + thread.join(timeout=2) + request = received.get_nowait() + assert request["method"] == "tools/call" + assert request["params"]["name"] == "brain_backup_vacuum_into" + assert request["params"]["arguments"]["target_path"].endswith("/2026-05-13.db") + assert snapshot.name == "2026-05-13.db.gz" + + def test_create_snapshot_rejects_low_disk_space(tmp_path, monkeypatch): from brainlayer import backup_daily @@ -120,3 +190,20 @@ def test_launchd_installer_knows_backup_target(): assert "com.brainlayer.backup-daily" in plist assert "3" in plist assert "17" in plist + assert "KeepAlive" not in plist + assert "ExitTimeOut" in plist + assert "300" in plist + assert "BRAINLAYER_BACKUP_TIMEOUT_SECONDS:=300" in wrapper + + +def test_main_enforces_configured_backup_timeout(monkeypatch, capsys): + from brainlayer import backup_daily + + def slow_backup(**kwargs): # noqa: ARG001 + time.sleep(5) + + monkeypatch.setenv("BRAINLAYER_BACKUP_TIMEOUT_SECONDS", "1") + monkeypatch.setattr(backup_daily, "run_backup", slow_backup) + + assert backup_daily.main() == 124 + assert "brainlayer backup timed out after 1s" in capsys.readouterr().out