Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 95 additions & 11 deletions crates/buzz-db/src/partition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,47 @@

use chrono::{Datelike, TimeZone, Utc};
use sqlx::{PgPool, Row};
use tracing::info;
use tracing::{info, warn};

use crate::error::{DbError, Result};

/// True when a `sqlx::Error` is a Postgres `42P17` ("would overlap partition")
/// raised by a `CREATE PARTITION` whose range is already covered. Fresh schemas
/// include a right-edge catch-all (`*_p_future`), so a fresh install collides
/// on the "current month" boundary it tries to add. Split out of
/// `ensure_partition` so the classification is unit-testable without Postgres.
fn is_partition_overlap_error(err: &sqlx::Error) -> bool {
match err {
sqlx::Error::Database(db) => {
db.code().as_deref() == Some("42P17")
&& db.message().contains("would overlap partition")
}
_ => false,
}
}

/// Record #4033: a monthly partition attempt was absorbed by the `*_p_future`
/// catch-all (monthly partition NOT created). Metric so operators can alert
/// independent of log level.
fn catch_all_coverage_metric(table: &'static str) {
metrics::counter!(
"buzz_db_partition_catchall_coverage",
"table" => table,
)
.increment(1);
}

/// Resolve a validated table name to its static allowlist entry so the metric
/// label escapes the caller's stack. `ensure_partition` already validated the
/// name against `PARTITIONED_TABLES`; `.expect` is unreachable for valid input.
fn static_table_name(table: &str) -> &'static str {
PARTITIONED_TABLES
.iter()
.copied()
.find(|t| *t == table)
.expect("table name validated against PARTITIONED_TABLES")
}

/// Tables that may be partition-managed. Allowlist prevents DDL injection.
const PARTITIONED_TABLES: &[&str] = &["events", "delivery_log"];

Expand Down Expand Up @@ -132,17 +169,20 @@ async fn ensure_partition(
info!("added partition {partition_name}");
Ok(())
}
Err(sqlx::Error::Database(db_err))
if db_err.code().as_deref() == Some("42P17")
&& db_err.message().contains("would overlap partition") =>
{
// Fresh schemas include a right-edge catch-all partition (`*_p_future`).
// If it already covers this month, the table is still safe for writes;
// treat the overlap as "ensured" rather than failing startup.
info!(
partition_name,
"partition range already covered by an existing partition"
Err(e) if is_partition_overlap_error(&e) => {
// #4033: the `*_p_future` catch-all already covers this month, so the
// monthly partition was NOT created and never will be — every row for
// this range keeps landing in the catch-all. Postgres only logs an
// unsuppressable server-side ERROR; the app must NOT surface this as a
// silent `info!` success. Escalate to `warn!` and emit a metric so the
// growing catch-all is alertable, but still return Ok: writes are safe
// and startup must not fail.
warn!(
partition = %partition_name,
table = %table_name,
"monthly partition was NOT created: range already covered by the `*_p_future` catch-all (Postgres 42P17); rows for this range keep accumulating in the catch-all. Re-base or split the catch-all to restore monthly pruning/archival."
);
catch_all_coverage_metric(static_table_name(table_name));
Ok(())
}
Err(e) => Err(e.into()),
Expand All @@ -153,6 +193,50 @@ async fn ensure_partition(
mod tests {
use super::*;

#[test]
fn overlap_predicate_classifies_42p17() {
// We cannot easily construct a real sqlx::Error::Database without a
// Postgres connection, so this documents the contract: the predicate
// only matches sqlx::Error::Database variants whose code is 42P17 and
// whose message contains "would overlap partition" (covered by the
// 42P17-catch-all arm in `ensure_partition`); every other sqlx::Error
// variant (PoolClosed here) must NOT match.
let non_db = sqlx::Error::PoolClosed;
assert!(!is_partition_overlap_error(&non_db));
}

#[test]
fn catchall_metric_emits_under_local_recorder() {
use metrics_util::debugging::DebuggingRecorder;

let recorder = DebuggingRecorder::new();
let snapshotter = recorder.snapshotter();
let guard = metrics::set_default_local_recorder(&recorder);
catch_all_coverage_metric("events");
drop(guard);

let hit = snapshotter
.snapshot()
.into_vec()
.into_iter()
.filter(|(key, ..)| key.key().name() == "buzz_db_partition_catchall_coverage")
.map(|(key, _, _, value)| {
let metrics_util::debugging::DebugValue::Counter(n) = value else {
panic!("must be a counter");
};
let labels: Vec<_> = key.key().labels().collect();
let table = labels
.iter()
.find(|l| l.key() == "table")
.map(|l| l.value().to_owned())
.unwrap_or_default();
(table, n)
})
.collect::<Vec<_>>();

assert_eq!(hit, vec![("events".to_owned(), 1)]);
}

#[test]
fn suffix_validation() {
assert!(validate_partition_suffix("2026_03"));
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ pub struct AppState {
pub managed_agent_processes: Mutex<HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>>,
pub huddle_state: Mutex<HuddleState>,
pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState,
/// macOS-only close-to-tray behavior (#4024). Resolved during setup
/// from `close_to_tray::load_for_app`; persisted in close-to-tray.json.
pub close_to_tray_behavior: Mutex<crate::close_to_tray::CloseToTrayBehavior>,
/// Tauri app handle — stored after setup so huddle commands can emit
/// `huddle-state-changed` events without needing the handle threaded
/// through every call site.
Expand Down Expand Up @@ -218,6 +221,7 @@ pub fn build_app_state() -> AppState {
session_config_cache: Mutex::new(HashMap::new()),
huddle_state: Mutex::new(HuddleState::default()),
huddle_audio: Default::default(),
close_to_tray_behavior: Mutex::new(crate::close_to_tray::CloseToTrayBehavior::default()),
app_handle: Mutex::new(None),
media_proxy_port: AtomicU16::new(0),
prevent_sleep: Arc::new(Mutex::new(
Expand Down
214 changes: 214 additions & 0 deletions desktop/src-tauri/src/close_to_tray.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//! macOS close-to-tray behavior preference (#4024).
//!
//! Closing Buzz's main window keeps the process and local agents running but
//! hides the only main window. That suits background agent work, but a user on
//! a window-switcher that excludes hidden windows (e.g. BetterTouchTool) loses
//! Buzz from the switcher even though it is still running — the app becomes
//! "windowless" with no way back through the normal switching workflow.
//!
//! This module persists a per-installation preference controlling what the
//! macOS close handler does. The default stays `KeepRunning` (current
//! behavior), preserving active agent work; `QuitWhenClosed` gives users who
//! prefer conventional visible-window app switching an explicit opt-out.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager, State};

use crate::{app_state::AppState, managed_agents::storage::atomic_write_json_restricted};

const SETTINGS_FILE: &str = "close-to-tray.json";
const CURRENT_VERSION: u32 = 1;

/// What the macOS handler does when the main window is closed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CloseToTrayBehavior {
/// Keep Buzz running in the menu bar with the window hidden (default,
/// preserves active agent sessions). Selected on the initial install and
/// whenever the setting file is missing or unreadable.
#[serde(rename = "keepRunning")]
KeepRunning,
/// Minimize the window to the Dock instead of hiding it, keeping Buzz
/// visible to window switchers that exclude hidden windows.
#[serde(rename = "minimizeToTray")]
MinimizeToTray,
/// Quit Buzz when the last main window closes, for users who do not want
/// a windowless background process.
#[serde(rename = "quitWhenClosed")]
QuitWhenClosed,
}

impl Default for CloseToTrayBehavior {
/// Default to the current unconditional behavior (`keepRunning`) so an
/// upgrade without the setting behaves identically to before.
fn default() -> Self {
Self::KeepRunning
}
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CloseToTraySettings {
version: u32,
behavior: CloseToTrayBehavior,
}

pub(crate) fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map(|dir| dir.join(SETTINGS_FILE))
.map_err(|error| format!("could not locate Buzz settings storage: {error}"))
}

pub(crate) fn load_from_path(path: &Path) -> Result<CloseToTraySettings, String> {
if !path.exists() {
return Ok(CloseToTraySettings {
version: CURRENT_VERSION,
behavior: CloseToTrayBehavior::default(),
});
}
let bytes = std::fs::read(path)
.map_err(|error| format!("could not read close-to-tray settings: {error}"))?;
let value: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|error| format!("close-to-tray settings are not valid JSON: {error}"))?;

// Unversioned settings are incompatible with the V1 schema — use explicit
// defaults rather than interpret ambiguous fields.
let version = match value.get("version").and_then(serde_json::Value::as_u64) {
None => {
return Ok(CloseToTraySettings {
version: CURRENT_VERSION,
behavior: CloseToTrayBehavior::default(),
});
}
Some(v) => v,
};
if version > u64::from(CURRENT_VERSION) {
return Err(format!(
"close-to-tray settings version {version} is newer than this Buzz build supports"
));
}
serde_json::from_value(value)
.map_err(|error| format!("close-to-tray settings are invalid: {error}"))
}

pub(crate) fn save_to_path(path: &Path, settings: &CloseToTraySettings) -> Result<(), String> {
let payload = serde_json::to_vec_pretty(settings)
.map_err(|error| format!("could not encode close-to-tray settings: {error}"))?;
atomic_write_json_restricted(path, &payload)
.map_err(|error| format!("could not save close-to-tray settings: {error}"))
}

pub fn load_for_app(app: &AppHandle) -> CloseToTrayBehavior {
match settings_path(app).and_then(|path| load_from_path(&path)) {
Ok(settings) => settings.behavior,
Err(error) => {
eprintln!("buzz-desktop: {error}; keeping windows hidden on close for this session");
CloseToTrayBehavior::default()
}
}
}

#[tauri::command]
pub fn get_close_to_tray_behavior(state: State<'_, AppState>) -> Result<String, String> {
let behavior = *state
.close_to_tray_behavior
.lock()
.map_err(|lock_error| format!("close-to-tray settings lock poisoned: {lock_error}"))?;
Ok(match behavior {
CloseToTrayBehavior::KeepRunning => "keepRunning",
CloseToTrayBehavior::MinimizeToTray => "minimizeToTray",
CloseToTrayBehavior::QuitWhenClosed => "quitWhenClosed",
}
.to_string())
}

#[tauri::command]
pub fn set_close_to_tray_behavior(
app: AppHandle,
state: State<'_, AppState>,
behavior: String,
) -> Result<(), String> {
let behavior = match behavior.as_str() {
"keepRunning" => CloseToTrayBehavior::KeepRunning,
"minimizeToTray" => CloseToTrayBehavior::MinimizeToTray,
"quitWhenClosed" => CloseToTrayBehavior::QuitWhenClosed,
other => return Err(format!("unsupported close-to-tray behavior: {other}")),
};
{
let mut stored = state
.close_to_tray_behavior
.lock()
.map_err(|lock_error| format!("close-to-tray settings lock poisoned: {lock_error}"))?;
*stored = behavior;
}
let settings = CloseToTraySettings {
version: CURRENT_VERSION,
behavior,
};
let path = settings_path(&app)?;
save_to_path(&path, &settings)
.map_err(|error| format!("could not persist close-to-tray settings: {error}"))
}

#[cfg(test)]
mod tests {
use super::*;

fn temp_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("temp dir")
}

#[test]
fn default_when_missing() {
let dir = temp_dir();
let path = dir.path().join(SETTINGS_FILE);
let settings = load_from_path(&path).expect("missing file must load defaults");
assert_eq!(settings.behavior, CloseToTrayBehavior::KeepRunning);
assert_eq!(settings.version, CURRENT_VERSION);
}

#[test]
fn round_trip_persists_behavior() {
let dir = temp_dir();
let path = dir.path().join(SETTINGS_FILE);
let settings = CloseToTraySettings {
version: CURRENT_VERSION,
behavior: CloseToTrayBehavior::QuitWhenClosed,
};
save_to_path(&path, &settings).expect("save");
let loaded = load_from_path(&path).expect("load");
assert_eq!(loaded.behavior, CloseToTrayBehavior::QuitWhenClosed);
}

#[test]
fn unversioned_file_falls_back_to_default() {
let dir = temp_dir();
let path = dir.path().join(SETTINGS_FILE);
std::fs::write(&path, br##"{"behavior":"quitWhenClosed"}"##).unwrap();
let loaded = load_from_path(&path).expect("unversioned must load default");
assert_eq!(loaded.behavior, CloseToTrayBehavior::KeepRunning);
}

#[test]
fn newer_version_is_an_error() {
let dir = temp_dir();
let path = dir.path().join(SETTINGS_FILE);
std::fs::write(
&path,
br##"{"version":999,"behavior":"keepRunning"}"##,
)
.unwrap();
assert!(load_from_path(&path).is_err());
}

#[test]
fn invalid_json_is_an_error() {
let dir = temp_dir();
let path = dir.path().join(SETTINGS_FILE);
std::fs::write(&path, b"not json").unwrap();
assert!(load_from_path(&path).is_err());
}
}
Loading