diff --git a/docs/release-notes/v0.3.11/en.md b/docs/release-notes/v0.3.11/en.md new file mode 100644 index 0000000..193b7e6 --- /dev/null +++ b/docs/release-notes/v0.3.11/en.md @@ -0,0 +1,9 @@ +## Improved + +- The sidebar contact entry now invites Traditional Chinese, Japanese, and English users to join the [Discord server](https://discord.gg/PxvX4D9kgs). The Simplified Chinese entry continues to open the existing QQ group. + +## Fixed + +- Fixed WorkBuddy / WorkBuddy AI detection on macOS by locating the executable named in the app bundle's `Info.plist`. Detection still uses the existing path when that information is missing or invalid. +- Fixed subsequent core configuration changes sometimes failing to reach a running core after repeated saves. Settings such as usage statistics and client API keys now take effect when saved. +- Fixed existing OAuth credentials not being restored from the macOS update backup when the replacement app bundle already contains an empty OAuth directory. diff --git a/docs/release-notes/v0.3.11/ja.md b/docs/release-notes/v0.3.11/ja.md new file mode 100644 index 0000000..178febf --- /dev/null +++ b/docs/release-notes/v0.3.11/ja.md @@ -0,0 +1,9 @@ +## 改善 + +- 繁体字中国語・日本語・英語の画面では、サイドバーの連絡先を「Discord サーバーに参加」に変更しました。クリックすると [Discord サーバー](https://discord.gg/PxvX4D9kgs) が開きます。簡体字中国語の画面では、従来の QQ リンクを引き続き使用します。 + +## 修正 + +- macOS で WorkBuddy / WorkBuddy AI を検出できない場合がある問題を修正しました。アプリバンドルの `Info.plist` に記載された実行ファイル名を使用し、情報がない場合や無効な場合は従来のパスを使用します。 +- コアの設定を繰り返し保存した後、実行中のコアが後続の変更を読み込まなくなる場合がある問題を修正しました。使用統計やクライアント API キーなどの設定が保存後に反映されます。 +- macOS の更新後、新しいアプリバンドル内に空の OAuth ディレクトリがあると、更新バックアップから既存の認証情報が復元されない問題を修正しました。 diff --git a/docs/release-notes/v0.3.11/zh-CN.md b/docs/release-notes/v0.3.11/zh-CN.md new file mode 100644 index 0000000..4d607ab --- /dev/null +++ b/docs/release-notes/v0.3.11/zh-CN.md @@ -0,0 +1,9 @@ +## 优化 + +- 优化联系方式。 + +## 修复 + +- 修复 macOS 上 WorkBuddy / WorkBuddy AI 可能无法被检测到的问题:根据应用包 `Info.plist` 中的可执行文件名定位程序;信息缺失或无效时仍兼容原路径。 +- 修复连续保存内核配置后,运行中的内核可能无法读取后续变更的问题;使用统计开关、客户端 API Key 等设置现在可在保存后正常生效。 +- 修复 macOS 更新后,新应用包内的 OAuth 目录已存在但为空时,旧凭据未从更新备份恢复的问题。 diff --git a/docs/release-notes/v0.3.11/zh-TW.md b/docs/release-notes/v0.3.11/zh-TW.md new file mode 100644 index 0000000..cd1695d --- /dev/null +++ b/docs/release-notes/v0.3.11/zh-TW.md @@ -0,0 +1,9 @@ +## 優化 + +- 繁體中文、日文和英文介面的側邊欄社群入口改為「加入 Discord 伺服器」,點擊後開啟 [Discord 社群](https://discord.gg/PxvX4D9kgs);簡體中文介面仍使用原有的 QQ 入口。 + +## 修復 + +- 修復 macOS 上可能無法偵測到 WorkBuddy / WorkBuddy AI 的問題:依照應用程式套件 `Info.plist` 記錄的執行檔名稱定位程式;資訊缺失或無效時仍相容原有路徑。 +- 修復連續儲存核心設定後,執行中的核心可能無法讀取後續變更的問題;使用統計開關、用戶端 API Key 等設定現在可在儲存後正常生效。 +- 修復 macOS 更新後,新應用程式套件內的 OAuth 目錄已存在但為空時,舊憑據未從更新備份還原的問題。 diff --git a/src-tauri/src/configuration_watcher.rs b/src-tauri/src/configuration_watcher.rs index fbf50dd..b087fe3 100644 --- a/src-tauri/src/configuration_watcher.rs +++ b/src-tauri/src/configuration_watcher.rs @@ -3,7 +3,7 @@ use super::{ core_config_settings_from_value, core_install_dir, gui_config_path, is_loopback_host, lock_core_config_file, normalized_config_path, path_to_string, refresh_agent_config_status_cache, request_codex_model_catalog_refresh, validate_gui_config, - write_yaml_if_changed, AgentClient, AgentConfigStatusCache, ConfigFilesChangedPayload, + write_core_config_if_changed, AgentClient, AgentConfigStatusCache, ConfigFilesChangedPayload, CoreConfigSettings, GuiConfigFile, GuiConfigState, CONFIG_FILES_CHANGED_EVENT, CORE_CONFIG_FILE, }; @@ -53,7 +53,7 @@ fn patch_core_from_gui_config_if_valid(config: &GuiConfigFile) -> Result<(), Str let content = fs::read_to_string(&path) .map_err(|error| format!("Failed to read kernel configuration {}: {error}", path_to_string(&path)))?; let updated = apply_gui_managed_settings(&content, config)?; - write_yaml_if_changed(&path, &updated).map(|_| ()) + write_core_config_if_changed(&path, &updated).map(|_| ()) } fn tracked_configuration_paths(app: &tauri::AppHandle) -> Result, String> { diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 39dcaf9..164b946 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -775,6 +775,14 @@ pub(crate) fn write_yaml_if_changed(path: &Path, content: &str) -> Result Result { + if fs::read_to_string(path).ok().as_deref() == Some(content) { + return Ok(false); + } + write_bytes_directly(path, content.as_bytes())?; + Ok(true) +} + #[cfg(not(windows))] pub(crate) fn replace_file_atomically( temporary_path: &Path, @@ -1155,9 +1163,22 @@ pub(crate) fn migrate_auth_dir_from_macos_app_bundle( )); } - let migration_source = match fs::symlink_metadata(&source) { - Ok(_) => source.clone(), - Err(error) if error.kind() == io::ErrorKind::NotFound => source + let source_is_empty = match fs::symlink_metadata(&source) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => fs::read_dir(&source) + .and_then(|mut entries| entries.next().transpose()) + .map_err(|error| format!("Failed to inspect OAuth directory {}: {error}", path_to_string(&source)))? + .is_none(), + Ok(_) => false, + Err(error) if error.kind() == io::ErrorKind::NotFound => true, + Err(error) => { + return Err(format!( + "Failed to check old OAuth directory {}: {error}", + path_to_string(&source) + )); + } + }; + let migration_source = if source_is_empty { + source .ancestors() .find_map(|directory| { if directory.file_name()? != std::ffi::OsStr::new("Contents") { @@ -1173,13 +1194,9 @@ pub(crate) fn migrate_auth_dir_from_macos_app_bundle( .join(source.strip_prefix(app).ok()?), ) }) - .unwrap_or_else(|| source.clone()), - Err(error) => { - return Err(format!( - "Failed to check old OAuth directory {}: {error}", - path_to_string(&source) - )); - } + .unwrap_or_else(|| source.clone()) + } else { + source.clone() }; copy_auth_directory(&migration_source, &destination)?; config.auth_dir = DEFAULT_AUTH_DIR.to_string(); diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index 5859386..d10cc51 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -31,7 +31,7 @@ pub(crate) fn merge_core_config_for_start( None }; let merged = merge_core_config_yaml(&template, current.as_deref(), gui_config)?; - write_yaml_if_changed(&config_path, &merged)?; + write_core_config_if_changed(&config_path, &merged)?; Ok(config_path) } @@ -49,7 +49,7 @@ pub(crate) fn patch_core_network_settings(config: &GuiConfigFile) -> Result<(), let Some(updated) = patch_core_network_yaml(&content, config)? else { return Ok(()); }; - write_yaml_if_changed(&config_path, &updated).map(|_| ()) + write_core_config_if_changed(&config_path, &updated).map(|_| ()) } pub(crate) fn patch_core_network_routing_settings(config: &GuiConfigFile) -> Result<(), String> { @@ -64,7 +64,7 @@ pub(crate) fn patch_core_network_routing_settings(config: &GuiConfigFile) -> Res let Some(updated) = patch_core_network_routing_yaml(&content, config)? else { return Ok(()); }; - write_yaml_if_changed(&config_path, &updated).map(|_| ()) + write_core_config_if_changed(&config_path, &updated).map(|_| ()) } fn patch_installed_core_config_with( @@ -84,7 +84,7 @@ fn patch_installed_core_config_with( let Some(updated) = patch(&content)? else { return Ok(()); }; - write_yaml_if_changed(&config_path, &updated).map(|_| ()) + write_core_config_if_changed(&config_path, &updated).map(|_| ()) } pub(crate) fn patch_core_network_endpoint_settings(config: &GuiConfigFile) -> Result<(), String> { @@ -225,7 +225,7 @@ pub(crate) fn patch_core_tls_settings(settings: &CoreTlsSettings) -> Result<(), let Some(updated) = patch_core_tls_settings_yaml(&content, settings)? else { return Ok(()); }; - write_yaml_if_changed(&config_path, &updated).map(|_| ()) + write_core_config_if_changed(&config_path, &updated).map(|_| ()) } pub(crate) fn normalize_core_sensitive_words_settings( @@ -379,7 +379,7 @@ pub(crate) fn patch_core_sensitive_words_settings( let content = fs::read_to_string(&config_path) .map_err(|error| format!("Failed to read kernel configuration {}: {error}", path_to_string(&config_path)))?; if let Some(updated) = patch_core_sensitive_words_yaml(&content, settings)? { - write_yaml_if_changed(&config_path, &updated)?; + write_core_config_if_changed(&config_path, &updated)?; } Ok(()) } @@ -394,7 +394,7 @@ pub(crate) fn patch_core_api_keys(api_keys: &[String]) -> Result<(), String> { let content = fs::read_to_string(&config_path) .map_err(|err| format!("Failed to read kernel configuration {}: {err}", path_to_string(&config_path)))?; let updated = patch_core_api_keys_yaml(&content, api_keys)?; - write_yaml_if_changed(&config_path, &updated)?; + write_core_config_if_changed(&config_path, &updated)?; Ok(()) } @@ -575,7 +575,7 @@ where return Ok(()); }; - write_yaml_if_changed(config_path, &updated)?; + write_core_config_if_changed(config_path, &updated)?; Ok(()) } diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index 8075317..dde13b8 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -802,6 +802,30 @@ fn packaged_macos_auth_migration_recovers_the_replaced_app_backup() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn packaged_macos_auth_migration_recovers_backup_when_new_directory_is_empty() { + let root = agent_test_home("macos-auth-empty-update-directory"); + let relative = Path::new("Contents/MacOS/oauth"); + let source = root.join("Applications/EasyCLIProxyAPI.app").join(relative); + let backup = root.join("Applications/.EasyCLIProxyAPI.app.update-backup").join(relative); + let persistent = root.join("Library/Application Support/com.cpa.gui"); + let install_dir = persistent.join("cpa-core"); + let destination = persistent.join(OAUTH_DIR_NAME); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&backup).unwrap(); + fs::write(backup.join("account.json"), b"existing-credential").unwrap(); + let mut config = GuiConfigFile { + auth_dir: path_to_string(&source), + ..GuiConfigFile::default() + }; + + assert!(migrate_auth_dir_from_macos_app_bundle(&mut config, &install_dir, &destination).unwrap()); + assert_eq!(fs::read(destination.join("account.json")).unwrap(), b"existing-credential"); + assert_eq!(fs::read(backup.join("account.json")).unwrap(), b"existing-credential"); + assert_eq!(config.auth_dir, DEFAULT_AUTH_DIR); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn packaged_macos_auth_migration_does_not_overwrite_conflicting_credentials() { let root = agent_test_home("packaged-macos-auth-conflict"); @@ -1876,6 +1900,28 @@ fn unchanged_yaml_is_not_written_again() { fs::remove_file(path).unwrap(); } +#[test] +fn core_config_saves_preserve_the_file_watched_by_the_running_core() { + let root = agent_test_home("core-config-watched-file"); + let path = root.join(CORE_CONFIG_FILE); + fs::write(&path, "auth-dir: old-directory\nusage-statistics-enabled: false\n").unwrap(); + let mut watched_file = File::open(&path).unwrap(); + + for directory in ["first-directory", "x"] { + patch_core_auth_dir_at(&path, directory).unwrap(); + watched_file.seek(SeekFrom::Start(0)).unwrap(); + let mut watched_content = String::new(); + watched_file.read_to_string(&mut watched_content).unwrap(); + assert_eq!(watched_content, fs::read_to_string(&path).unwrap()); + let document = serde_norway::from_str(&watched_content).unwrap(); + let settings = core_config_settings_from_value(&document).unwrap(); + assert_eq!(settings.auth_dir, directory); + assert!(!settings.usage_statistics_enabled); + } + drop(watched_file); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { let template = "# Current release template\nhost: \"\" # template bind address\nport: 8317\n\n# Client authentication\napi-keys:\n - template-key\n\n# Plugin runtime\nplugins:\n enabled: false # plugin switch\n\n# Credential routing\nrouting:\n strategy: round-robin # routing switch\n\n# New release option\nnew-option: true\nnested:\n # Nested template comment\n keep: template\n added: from-template\nlist:\n - template-item\n"; diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index 3ed59d0..bd46fab 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -620,8 +620,8 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() { fs::create_dir_all(root.join("oauth")).unwrap(); let credential_path = root.join("oauth/account.json"); fs::write(&credential_path, b"existing-credential").unwrap(); - let legacy = "# User configuration\nhost: 127.0.0.1\nport: 9527\nauth-dir: ../oauth\napi-keys: [client-key]\nremote-management: {secret-key: user-secret}\nproxy-url: direct\nrequest-retry: 9\ncodex-api-key: [{api-key: upstream-key, base-url: 'https://example.invalid/v1'}]\noauth-model-alias: {codex: [{name: model, alias: custom-model}]}\n"; - let template = "server: {host: '', port: 8317}\noauth: {auth-dir: '~/.cli-proxy-api'}\naccess: {api-keys: [your-api-key-1]}\nmanagement: {secret-key: ''}\nrequests: {proxy-url: ''}\nrouting: {retry: {request-retry: 3}}\n"; + let legacy = "# User configuration\nusage-statistics-enabled: true\nhost: 127.0.0.1\nport: 9527\nauth-dir: ../oauth\napi-keys: [client-key]\nremote-management: {secret-key: user-secret}\nproxy-url: direct\nrequest-retry: 9\ncodex-api-key: [{api-key: upstream-key, base-url: 'https://example.invalid/v1'}]\noauth-model-alias: {codex: [{name: model, alias: custom-model}]}\n"; + let template = "observability: {usage: {usage-statistics-enabled: false}}\nserver: {host: '', port: 8317}\noauth: {auth-dir: '~/.cli-proxy-api'}\naccess: {api-keys: [your-api-key-1]}\nmanagement: {secret-key: ''}\nrequests: {proxy-url: ''}\nrouting: {retry: {request-retry: 3}}\n"; for prefix in ["", "config-version: 8\n", "server: {host: 127.0.0.1}\n", "config-version: 8\nserver: {host: 127.0.0.1}\n"] { let original = format!("{prefix}{legacy}"); @@ -637,6 +637,7 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() { let settings = core_config_settings_from_value(&document).unwrap(); assert_eq!(settings.auth_dir, "../oauth", "credential directory changed during update"); assert_eq!(settings.port, 9527); + assert!(settings.usage_statistics_enabled); assert_eq!(settings.api_keys, vec!["client-key"]); assert_eq!(settings.management_secret_key.as_deref(), Some("user-secret")); assert_eq!(settings.proxy_url, "direct"); @@ -650,6 +651,7 @@ fn v8_core_updates_preserve_partially_migrated_config_and_credentials() { let startup = serde_norway::from_str::(&startup).unwrap(); let effective = core_config_settings_from_value(&startup).unwrap(); assert_eq!(auth_dir_path_for_core(&effective.auth_dir, &install_dir).unwrap(), root.join("oauth")); + assert!(effective.usage_statistics_enabled); assert_eq!(startup["codex-api-key"], document["codex-api-key"]); assert_eq!(startup["oauth-model-alias"], document["oauth-model-alias"]); assert_eq!(fs::read(&credential_path).unwrap(), b"existing-credential"); diff --git a/src-tauri/src/tests/core_v8_contract.rs b/src-tauri/src/tests/core_v8_contract.rs index 17c15c5..f3737e9 100644 --- a/src-tauri/src/tests/core_v8_contract.rs +++ b/src-tauri/src/tests/core_v8_contract.rs @@ -14,6 +14,12 @@ struct TestCore { impl Drop for TestCore { fn drop(&mut self) { self.stop(); + if std::thread::panicking() { + eprintln!( + "{}", + fs::read_to_string(self.directory.join("core.log")).unwrap_or_default() + ); + } if self.directory.parent() == Some(std::env::temp_dir().as_path()) && self .directory @@ -43,7 +49,9 @@ impl TestCore { .arg("-local-model") .current_dir(&self.directory) .stdin(Stdio::null()) - .stdout(Stdio::null()) + .stdout(Stdio::from( + File::create(self.directory.join("core.log")).unwrap(), + )) .stderr(Stdio::null()); configure_background_command(&mut command); self.child = Some(command.spawn().unwrap()); @@ -268,7 +276,7 @@ async fn v8_accepts_gui_settings_and_reloads_client_keys() { ); let current = fs::read_to_string(&core.config).unwrap(); let content = patch_core_api_keys_yaml(¤t, &["client-two".into()]).unwrap(); - write_yaml_if_changed(&core.config, &content).unwrap(); + write_core_config_if_changed(&core.config, &content).unwrap(); core.wait_for_client_key("client-two", 200).await; core.wait_for_client_key("client-one", 401).await; core.stop(); @@ -276,3 +284,104 @@ async fn v8_accepts_gui_settings_and_reloads_client_keys() { core.wait_for_client_key("client-two", 200).await; core.wait_for_client_key("client-one", 401).await; } + +#[tokio::test] +#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"] +async fn v8_reloads_usage_after_repeated_gui_saves_in_both_config_layouts() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + for initial in [ + "remote-management: {disable-control-panel: true, disable-auto-update-panel: true}\nusage-statistics-enabled: false\n", + "config-version: 8\nmanagement: {disable-control-panel: true, disable-auto-update-panel: true}\nobservability: {usage: {usage-statistics-enabled: false}}\nusage-statistics-enabled: true\n", + ] { + let executable = fs::canonicalize(std::env::var_os("CPA_V8_TEST_CORE").expect("set CPA_V8_TEST_CORE")).unwrap(); + let directory = agent_test_home("v8-contract-usage"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let mut core = TestCore { + child: None, + config: directory.join("config.yaml"), + directory, + executable, + origin: format!("http://127.0.0.1:{port}"), + client: reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(2)).build().unwrap(), + }; + let gui = GuiConfigFile { + host: "127.0.0.1".into(), + port, + auth_dir: path_to_string(&core.directory.join("existing-credentials")), + management_secret_key: "isolated-test-secret".into(), + usage_statistics_enabled: false, + ..GuiConfigFile::default() + }; + let upstream = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let upstream_port = upstream.local_addr().unwrap().port(); + let upstream_task = tokio::spawn(async move { + for _ in 0..3 { + let (mut stream, _) = upstream.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let read = stream.read(&mut buffer).await.unwrap(); + assert!(read > 0); + request.extend_from_slice(&buffer[..read]); + if let Some(end) = request.windows(4).position(|window| window == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]); + let length = headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse::().unwrap()) + }).unwrap_or(0); + if request.len() >= end + 4 + length { + break; + } + } + } + let body = r#"{"id":"usage-test","object":"chat.completion","model":"usage-probe","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}"#; + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + fs::create_dir_all(&gui.auth_dir).unwrap(); + let initial = format!("{initial}openai-compatibility:\n - name: isolated-usage-test\n base-url: http://127.0.0.1:{upstream_port}/v1\n api-key-entries: [{{api-key: isolated-upstream-key}}]\n models: [{{name: usage-probe, alias: usage-probe}}]\n"); + let content = apply_gui_managed_settings(&initial, &gui).unwrap(); + fs::write(&core.config, &content).unwrap(); + core.start().await; + tokio::time::sleep(Duration::from_secs(1)).await; + assert_eq!(core.config_view().await["observability"]["usage"]["usage-statistics-enabled"], false); + + for enabled in [true, false, true] { + let content = fs::read_to_string(&core.config).unwrap(); + let mut settings = core_config_settings_from_value(&serde_norway::from_str(&content).unwrap()).unwrap(); + assert_eq!(settings.auth_dir, gui.auth_dir); + settings.usage_statistics_enabled = enabled; + let updated = patch_core_yaml_document(&content, |document| apply_core_logging_settings(document, &settings)).unwrap().unwrap(); + write_core_config_if_changed(&core.config, &updated).unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + let saved = fs::read_to_string(&core.config).unwrap(); + let settings = core_config_settings_from_value(&serde_norway::from_str(&saved).unwrap()).unwrap(); + assert_eq!(settings.auth_dir, gui.auth_dir); + assert_eq!(settings.usage_statistics_enabled, enabled); + core.client.post(format!("{}/v1/chat/completions", core.origin)) + .bearer_auth(&gui.api_keys[0].key) + .json(&serde_json::json!({"model": "usage-probe", "messages": [{"role": "user", "content": "test"}]})) + .send().await.unwrap().error_for_status().unwrap() + .bytes().await.unwrap(); + let mut records = Vec::new(); + for _ in 0..10 { + tokio::time::sleep(Duration::from_millis(100)).await; + let batch: Vec = core.client + .get(format!("{}/v8/management/observability/usage/queue?count=100", core.origin)) + .bearer_auth("isolated-test-secret") + .send().await.unwrap().error_for_status().unwrap() + .json().await.unwrap(); + records.extend(batch); + if !records.is_empty() { + break; + } + } + assert_eq!(records.len(), usize::from(enabled), "usage output did not follow the saved setting"); + } + upstream_task.await.unwrap(); + } +}