diff --git a/apps/playwright.config.ts b/apps/playwright.config.ts index 71bcef2b8..dcbd2fc3b 100644 --- a/apps/playwright.config.ts +++ b/apps/playwright.config.ts @@ -1,23 +1,34 @@ import { defineConfig } from "@playwright/test"; const PORT = 3200; +const LOCAL_TEST_HOST = "localhost"; + +const noProxyHosts = ["localhost", "127.0.0.1", "::1"]; +const noProxy = [process.env.NO_PROXY, process.env.no_proxy, ...noProxyHosts] + .filter(Boolean) + .join(","); + +process.env.NO_PROXY = noProxy; +process.env.no_proxy = noProxy; export default defineConfig({ testDir: "./tests", timeout: 30_000, fullyParallel: false, use: { - baseURL: `http://127.0.0.1:${PORT}`, + baseURL: `http://${LOCAL_TEST_HOST}:${PORT}`, trace: "on-first-retry", video: "retain-on-failure", }, webServer: { - command: "node tests/support/static-server.mjs", - url: `http://127.0.0.1:${PORT}`, - reuseExistingServer: true, + command: "pnpm run build:desktop && node tests/support/static-server.mjs", + url: `http://${LOCAL_TEST_HOST}:${PORT}`, + reuseExistingServer: false, timeout: 120_000, env: { + NO_PROXY: noProxy, PORT: String(PORT), + no_proxy: noProxy, }, }, }); diff --git a/apps/src-tauri/src/lib.rs b/apps/src-tauri/src/lib.rs index c8d496ff8..87c0f861b 100644 --- a/apps/src-tauri/src/lib.rs +++ b/apps/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ -use tauri::Manager; +use serde::Serialize; +use tauri::{Emitter, Manager}; mod app_shell; mod app_storage; @@ -12,6 +13,16 @@ use app_shell::{ CLOSE_TO_TRAY_ON_CLOSE, TRAY_AVAILABLE, }; +const USAGE_REFRESH_COMPLETED_EVENT: &str = "usage-refresh-completed"; + +#[derive(Clone, Serialize)] +struct UsageRefreshCompletedPayload { + source: &'static str, + processed: usize, + total: usize, + completed_at: i64, +} + /// 函数 `run` /// /// 作者: gaohongshun @@ -49,6 +60,20 @@ pub fn run() { if let Ok(log_dir) = app.path().app_log_dir() { log::info!("log dir: {}", log_dir.display()); } + let usage_refresh_event_app = app.handle().clone(); + codexmanager_service::set_usage_refresh_completed_handler(move |event| { + let payload = UsageRefreshCompletedPayload { + source: event.source, + processed: event.processed, + total: event.total, + completed_at: event.completed_at, + }; + if let Err(err) = usage_refresh_event_app + .emit(USAGE_REFRESH_COMPLETED_EVENT, payload) + { + log::warn!("emit usage refresh completed event failed: {}", err); + } + }); if let Err(err) = setup_tray(app.handle()) { TRAY_AVAILABLE.store(false, std::sync::atomic::Ordering::Relaxed); CLOSE_TO_TRAY_ON_CLOSE.store(false, std::sync::atomic::Ordering::Relaxed); diff --git a/apps/src/hooks/useAccounts.ts b/apps/src/hooks/useAccounts.ts index c0ce0fff4..1b92ca411 100644 --- a/apps/src/hooks/useAccounts.ts +++ b/apps/src/hooks/useAccounts.ts @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { accountClient } from "@/lib/api/account-client"; @@ -10,13 +10,14 @@ import { STARTUP_SNAPSHOT_REQUEST_LOG_LIMIT, } from "@/lib/api/startup-snapshot"; import { getAppErrorMessage } from "@/lib/api/transport"; +import { listenUsageRefreshCompleted } from "@/lib/api/usage-refresh-events"; import { useDesktopPageActive } from "@/hooks/useDesktopPageActive"; import { useDeferredDesktopActivation } from "@/hooks/useDeferredDesktopActivation"; import { useLocalDayRange } from "@/hooks/useLocalDayRange"; import { useRuntimeCapabilities } from "@/hooks/useRuntimeCapabilities"; import { useI18n } from "@/lib/i18n/provider"; import { useAppStore } from "@/lib/store/useAppStore"; -import { AccountListResult, StartupSnapshot } from "@/types"; +import { AccountListResult, AccountUsage, StartupSnapshot } from "@/types"; type ImportByDirectoryResult = Awaited>; type ImportByFileResult = Awaited>; @@ -112,6 +113,39 @@ function getAccountsAutoRefreshIntervalMs( return Math.max(1, intervalSecs) * 1000; } +function getUsageListRefreshIntervalMs( + enabled: boolean, + intervalSecs: number, +): number | false { + const intervalMs = getAccountsAutoRefreshIntervalMs(enabled, intervalSecs); + if (!intervalMs) { + return false; + } + return Math.min(5_000, intervalMs); +} + +function buildUsageListFingerprint(usages: AccountUsage[]): string { + if (usages.length === 0) { + return ""; + } + + return usages + .map((usage) => + [ + usage.accountId, + usage.capturedAt ?? "", + usage.usedPercent ?? "", + usage.secondaryUsedPercent ?? "", + usage.resetsAt ?? "", + usage.secondaryResetsAt ?? "", + usage.availabilityStatus ?? "", + usage.creditsJson ?? "", + ].join(":"), + ) + .sort() + .join("|"); +} + /** * 函数 `useAccounts` * @@ -141,6 +175,11 @@ export function useAccounts() { areAccountQueriesEnabled && backgroundTasks.usagePollingEnabled, backgroundTasks.usagePollIntervalSecs, ); + const usageListRefreshIntervalMs = getUsageListRefreshIntervalMs( + areAccountQueriesEnabled && backgroundTasks.usagePollingEnabled, + backgroundTasks.usagePollIntervalSecs, + ); + const usageListFingerprintRef = useRef(null); const startupSnapshot = queryClient.getQueryData( buildStartupSnapshotQueryKey( serviceStatus.addr, @@ -197,12 +236,79 @@ export function useAccounts() { queryFn: () => accountClient.listUsage(), enabled: areAccountQueriesEnabled, retry: 1, - refetchInterval: accountsAutoRefreshIntervalMs, + refetchInterval: usageListRefreshIntervalMs, refetchIntervalInBackground: false, placeholderData: (previousData) => previousData || (startupUsages.length > 0 ? startupUsages : undefined), }); + const usageListFingerprint = useMemo( + () => buildUsageListFingerprint(usagesQuery.data || []), + [usagesQuery.data], + ); + + useEffect(() => { + if (!areAccountQueriesEnabled) { + return; + } + + let disposed = false; + let unlisten: (() => void) | null = null; + const refreshVisibleUsageData = () => { + void Promise.all([ + queryClient.refetchQueries({ queryKey: ["usage", "list"], type: "active" }), + queryClient.refetchQueries({ queryKey: ["accounts", "list"], type: "active" }), + queryClient.invalidateQueries({ queryKey: ["usage-aggregate"] }), + queryClient.invalidateQueries({ queryKey: ["today-summary"] }), + queryClient.invalidateQueries({ queryKey: ["startup-snapshot"] }), + ]); + }; + + void listenUsageRefreshCompleted(() => { + refreshVisibleUsageData(); + }).then((cleanup) => { + if (disposed) { + cleanup(); + return; + } + unlisten = cleanup; + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, [areAccountQueriesEnabled, queryClient]); + + useEffect(() => { + if (!areAccountQueriesEnabled) { + usageListFingerprintRef.current = null; + return; + } + + if (!usagesQuery.isFetched) { + return; + } + + const previousFingerprint = usageListFingerprintRef.current; + usageListFingerprintRef.current = usageListFingerprint; + if (previousFingerprint == null || previousFingerprint === usageListFingerprint) { + return; + } + + void Promise.all([ + queryClient.invalidateQueries({ queryKey: ["accounts", "list"] }), + queryClient.invalidateQueries({ queryKey: ["usage-aggregate"] }), + queryClient.invalidateQueries({ queryKey: ["today-summary"] }), + queryClient.invalidateQueries({ queryKey: ["startup-snapshot"] }), + ]); + }, [ + areAccountQueriesEnabled, + queryClient, + usageListFingerprint, + usagesQuery.isFetched, + ]); + const accounts = useMemo(() => { return attachUsagesToAccounts( accountsQuery.data?.items || [], diff --git a/apps/src/lib/api/account-client.ts b/apps/src/lib/api/account-client.ts index 4f734bbe8..c6ce22fb6 100644 --- a/apps/src/lib/api/account-client.ts +++ b/apps/src/lib/api/account-client.ts @@ -401,6 +401,10 @@ export const accountClient = { ); return normalizeUsageSnapshot(unwrapUsageSnapshotPayload(result)); }, + async getLatestUsage(): Promise { + const result = await invoke("service_usage_read", withAddr()); + return normalizeUsageSnapshot(unwrapUsageSnapshotPayload(result)); + }, async listUsage(): Promise { const result = await invoke("service_usage_list", withAddr()); return normalizeUsageList(result); diff --git a/apps/src/lib/api/usage-refresh-events.ts b/apps/src/lib/api/usage-refresh-events.ts new file mode 100644 index 000000000..d736e2f4f --- /dev/null +++ b/apps/src/lib/api/usage-refresh-events.ts @@ -0,0 +1,53 @@ +import { isTauriRuntime } from "./transport"; + +export const USAGE_REFRESH_COMPLETED_EVENT = "usage-refresh-completed"; + +export interface UsageRefreshCompletedPayload { + source?: string; + processed?: number; + total?: number; + completedAt?: number; + completed_at?: number; +} + +export type UsageRefreshCompletedHandler = ( + payload: UsageRefreshCompletedPayload +) => void; + +type Unlisten = () => void; + +function readUsageRefreshEventPayload(event: Event): UsageRefreshCompletedPayload { + if (event instanceof CustomEvent && typeof event.detail === "object" && event.detail) { + return event.detail as UsageRefreshCompletedPayload; + } + return {}; +} + +export async function listenUsageRefreshCompleted( + handler: UsageRefreshCompletedHandler +): Promise { + if (typeof window === "undefined") { + return () => {}; + } + + const handleWindowEvent = (event: Event) => { + handler(readUsageRefreshEventPayload(event)); + }; + window.addEventListener(USAGE_REFRESH_COMPLETED_EVENT, handleWindowEvent); + + let unlistenTauri: Unlisten | null = null; + if (isTauriRuntime()) { + const { listen } = await import("@tauri-apps/api/event"); + unlistenTauri = await listen( + USAGE_REFRESH_COMPLETED_EVENT, + (event) => { + handler(event.payload || {}); + }, + ); + } + + return () => { + window.removeEventListener(USAGE_REFRESH_COMPLETED_EVENT, handleWindowEvent); + unlistenTauri?.(); + }; +} diff --git a/apps/tests/accounts-usage-auto-refresh.spec.ts b/apps/tests/accounts-usage-auto-refresh.spec.ts new file mode 100644 index 000000000..505aabc74 --- /dev/null +++ b/apps/tests/accounts-usage-auto-refresh.spec.ts @@ -0,0 +1,212 @@ +import { expect, test } from "@playwright/test"; + +const SETTINGS_SNAPSHOT = { + updateAutoCheck: true, + closeToTrayOnClose: false, + closeToTraySupported: false, + lowTransparency: false, + lightweightModeOnCloseToTray: false, + codexCliGuideDismissed: true, + webAccessPasswordConfigured: false, + locale: "zh-CN", + localeOptions: ["zh-CN", "en"], + serviceAddr: "localhost:48760", + serviceListenMode: "loopback", + serviceListenModeOptions: ["loopback", "all_interfaces"], + routeStrategy: "ordered", + routeStrategyOptions: ["ordered", "balanced"], + freeAccountMaxModel: "auto", + freeAccountMaxModelOptions: ["auto", "gpt-5"], + modelForwardRules: "", + accountMaxInflight: 1, + gatewayOriginator: "codex-cli", + gatewayOriginatorDefault: "codex-cli", + gatewayUserAgentVersion: "1.0.0", + gatewayUserAgentVersionDefault: "1.0.0", + gatewayResidencyRequirement: "", + gatewayResidencyRequirementOptions: ["", "us"], + pluginMarketMode: "builtin", + pluginMarketSourceUrl: "", + upstreamProxyUrl: "", + upstreamStreamTimeoutMs: 600000, + upstreamTotalTimeoutMs: 0, + sseKeepaliveIntervalMs: 15000, + backgroundTasks: { + usagePollingEnabled: true, + usagePollIntervalSecs: 30, + gatewayKeepaliveEnabled: true, + gatewayKeepaliveIntervalSecs: 180, + tokenRefreshPollingEnabled: true, + tokenRefreshPollIntervalSecs: 60, + usageRefreshWorkers: 4, + httpWorkerFactor: 4, + httpWorkerMin: 8, + httpStreamWorkerFactor: 1, + httpStreamWorkerMin: 2, + }, + envOverrides: {}, + envOverrideCatalog: [], + envOverrideReservedKeys: [], + envOverrideUnsupportedKeys: [], + theme: "tech", + appearancePreset: "classic", +}; + +const OLD_USAGE = { + accountId: "acct-auto-refresh", + availabilityStatus: "available", + usedPercent: 15, + windowMinutes: 300, + resetsAt: 1900000000, + secondaryUsedPercent: 25, + secondaryWindowMinutes: 10080, + secondaryResetsAt: 1900003600, + creditsJson: null, + capturedAt: 100, +}; + +const NEW_USAGE = { + ...OLD_USAGE, + usedPercent: 40, + capturedAt: 200, +}; + +const UNCHANGED_NEWER_USAGE = { + accountId: "acct-newer-snapshot", + availabilityStatus: "available", + usedPercent: 20, + windowMinutes: 300, + resetsAt: 1900007200, + secondaryUsedPercent: 30, + secondaryWindowMinutes: 10080, + secondaryResetsAt: 1900010800, + creditsJson: null, + capturedAt: 1_000, +}; + +test("accounts page refreshes usage after backend polling writes a new snapshot", async ({ + page, +}) => { + let usageListCount = 0; + let newSnapshotAvailable = false; + + await page.route("**/api/runtime", async (route) => { + await route.fulfill({ + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ + mode: "web-gateway", + rpcBaseUrl: "/api/rpc", + canManageService: false, + canSelfUpdate: false, + canCloseToTray: false, + canOpenLocalDir: false, + canUseBrowserFileImport: true, + canUseBrowserDownloadExport: true, + }), + }); + }); + + await page.route("**/api/rpc", async (route) => { + const payload = route.request().postDataJSON(); + const method = typeof payload?.method === "string" ? payload.method : ""; + const id = payload?.id ?? 1; + + const ok = (result: unknown) => + route.fulfill({ + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ + jsonrpc: "2.0", + id, + result, + }), + }); + + if (method === "appSettings/get") { + await ok(SETTINGS_SNAPSHOT); + return; + } + if (method === "initialize") { + await ok({ + userAgent: "codex_cli_rs/0.1.19", + codexHome: "C:/Users/Test/.codex", + platformFamily: "windows", + platformOs: "windows", + }); + return; + } + if (method === "account/list") { + await ok({ + items: [ + { + id: "acct-auto-refresh", + name: "auto-refresh@example.com", + label: "auto-refresh@example.com", + plan_type: "plus", + status: "active", + sort: 0, + }, + { + id: "acct-newer-snapshot", + name: "newer-snapshot@example.com", + label: "newer-snapshot@example.com", + plan_type: "plus", + status: "active", + sort: 1, + }, + ], + total: 2, + page: 1, + pageSize: 20, + }); + return; + } + if (method === "account/usage/read") { + await ok({ snapshot: UNCHANGED_NEWER_USAGE }); + return; + } + if (method === "account/usage/list") { + usageListCount += 1; + await ok({ + items: [ + newSnapshotAvailable ? NEW_USAGE : OLD_USAGE, + UNCHANGED_NEWER_USAGE, + ], + }); + return; + } + + await route.fulfill({ + status: 500, + contentType: "application/json; charset=utf-8", + body: JSON.stringify({ + jsonrpc: "2.0", + id, + error: { + code: -32000, + message: `Unhandled RPC method in test: ${method}`, + }, + }), + }); + }); + + await page.goto("/accounts/"); + + const row = page + .locator("tbody tr") + .filter({ hasText: "auto-refresh@example.com" }) + .first(); + + await expect(row.getByText("85%", { exact: true }).first()).toBeVisible(); + newSnapshotAvailable = true; + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent("usage-refresh-completed", { + detail: { source: "polling", processed: 1, total: 2 }, + }) + ); + }); + await expect(row.getByText("60%", { exact: true }).first()).toBeVisible({ + timeout: 2_000, + }); + expect(usageListCount).toBeGreaterThanOrEqual(2); +}); diff --git a/crates/service/src/lib.rs b/crates/service/src/lib.rs index c96425fff..20b7a8cb6 100644 --- a/crates/service/src/lib.rs +++ b/crates/service/src/lib.rs @@ -115,6 +115,7 @@ pub use auth::{rpc_auth_token, rpc_auth_token_matches}; pub use lifecycle::bootstrap::{initialize_storage_if_needed, portable}; pub use lifecycle::shutdown::{clear_shutdown_flag, request_shutdown, shutdown_requested}; pub use lifecycle::startup::{start_one_shot_server, start_server, ServerHandle}; +pub use usage_refresh::{set_usage_refresh_completed_handler, UsageRefreshCompletedEvent}; /// 函数 `test_env_guard` /// diff --git a/crates/service/src/usage/refresh/batch.rs b/crates/service/src/usage/refresh/batch.rs index 8e4007e32..58407965b 100644 --- a/crates/service/src/usage/refresh/batch.rs +++ b/crates/service/src/usage/refresh/batch.rs @@ -7,10 +7,11 @@ use std::thread; use std::time::{Duration, Instant}; use super::{ - build_workspace_map_from_accounts, open_storage, record_usage_refresh_failure, - record_usage_refresh_metrics, refresh_usage_for_token, DEFAULT_USAGE_POLL_BATCH_LIMIT, - DEFAULT_USAGE_POLL_CYCLE_BUDGET_SECS, ENV_USAGE_POLL_BATCH_LIMIT, - ENV_USAGE_POLL_CYCLE_BUDGET_SECS, USAGE_POLL_CURSOR, USAGE_REFRESH_WORKERS, + build_workspace_map_from_accounts, notify_usage_refresh_completed, open_storage, + record_usage_refresh_failure, record_usage_refresh_metrics, refresh_usage_for_token, + DEFAULT_USAGE_POLL_BATCH_LIMIT, DEFAULT_USAGE_POLL_CYCLE_BUDGET_SECS, + ENV_USAGE_POLL_BATCH_LIMIT, ENV_USAGE_POLL_CYCLE_BUDGET_SECS, USAGE_POLL_CURSOR, + USAGE_REFRESH_WORKERS, }; /// 函数 `refresh_usage_for_all_accounts` @@ -35,7 +36,9 @@ pub(crate) fn refresh_usage_for_all_accounts() -> Result<(), String> { if tasks.is_empty() { return Ok(()); } - run_usage_refresh_tasks(tasks)?; + let total = tasks.len(); + let processed = run_usage_refresh_tasks(tasks)?; + notify_usage_refresh_completed("manual_all", processed, total); Ok(()) } @@ -100,6 +103,7 @@ pub(crate) fn refresh_usage_for_polling_batch() -> Result<(), String> { cycle_budget.map(|budget| budget.as_secs()).unwrap_or(0) ); } + notify_usage_refresh_completed("polling", processed, total); Ok(()) } diff --git a/crates/service/src/usage/refresh/mod.rs b/crates/service/src/usage/refresh/mod.rs index bc2edb23c..ee2317abf 100644 --- a/crates/service/src/usage/refresh/mod.rs +++ b/crates/service/src/usage/refresh/mod.rs @@ -4,7 +4,7 @@ use codexmanager_core::usage::parse_usage_snapshot; use crossbeam_channel::unbounded; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize}; -use std::sync::OnceLock; +use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; @@ -129,6 +129,19 @@ struct UsageRefreshResult { _status: UsageAvailabilityStatus, } +#[derive(Debug, Clone)] +pub struct UsageRefreshCompletedEvent { + pub source: &'static str, + pub processed: usize, + pub total: usize, + pub completed_at: i64, +} + +type UsageRefreshCompletedHandler = Arc; + +static USAGE_REFRESH_COMPLETED_HANDLER: OnceLock>> = + OnceLock::new(); + pub(crate) use self::batch::refresh_usage_for_all_accounts; use self::batch::refresh_usage_for_polling_batch; #[cfg(test)] @@ -146,6 +159,31 @@ pub(crate) use self::settings::{ set_background_tasks_settings, BackgroundTasksSettingsPatch, }; +pub fn set_usage_refresh_completed_handler(handler: F) +where + F: Fn(UsageRefreshCompletedEvent) + Send + Sync + 'static, +{ + let slot = USAGE_REFRESH_COMPLETED_HANDLER.get_or_init(|| Mutex::new(None)); + let mut guard = crate::lock_utils::lock_recover(slot, "usage_refresh_completed_handler"); + *guard = Some(Arc::new(handler)); +} + +pub(crate) fn notify_usage_refresh_completed(source: &'static str, processed: usize, total: usize) { + let handler = USAGE_REFRESH_COMPLETED_HANDLER.get().and_then(|slot| { + let guard = crate::lock_utils::lock_recover(slot, "usage_refresh_completed_handler"); + guard.clone() + }); + + if let Some(handler) = handler { + handler(UsageRefreshCompletedEvent { + source, + processed, + total, + completed_at: now_ts(), + }); + } +} + /// 函数 `ensure_usage_polling` /// /// 作者: gaohongshun @@ -388,6 +426,7 @@ pub(crate) fn refresh_usage_for_account(account_id: &str) -> Result<(), String> } } record_usage_refresh_metrics(true, started_at); + notify_usage_refresh_completed("single", 1, 1); Ok(()) } diff --git a/crates/service/src/usage/tests/usage_refresh_tests.rs b/crates/service/src/usage/tests/usage_refresh_tests.rs index 6356dfcb7..fa9cbe1cb 100644 --- a/crates/service/src/usage/tests/usage_refresh_tests.rs +++ b/crates/service/src/usage/tests/usage_refresh_tests.rs @@ -1,7 +1,8 @@ use super::{ clear_pending_usage_refresh_tasks_for_tests, enqueue_usage_refresh_with_worker, - next_usage_poll_cursor, reset_usage_poll_cursor_for_tests, resolve_token_refresh_issuer, - run_token_refresh_task, should_retry_usage_refresh_with_token, token_refresh_access_exp_cutoff, + next_usage_poll_cursor, notify_usage_refresh_completed, reset_usage_poll_cursor_for_tests, + resolve_token_refresh_issuer, run_token_refresh_task, set_usage_refresh_completed_handler, + should_retry_usage_refresh_with_token, token_refresh_access_exp_cutoff, token_refresh_due_cutoff, token_refresh_schedule, usage_poll_batch_indices, }; use codexmanager_core::storage::{now_ts, Account, Storage, Token}; @@ -9,6 +10,24 @@ use std::collections::HashSet; use std::sync::mpsc; use std::time::Duration; +#[test] +fn usage_refresh_completed_handler_receives_notification() { + let _guard = crate::test_env_guard(); + let (tx, rx) = mpsc::channel(); + set_usage_refresh_completed_handler(move |event| { + let _ = tx.send(event); + }); + + notify_usage_refresh_completed("test-notify", 2, 3); + let event = rx + .recv_timeout(Duration::from_secs(1)) + .expect("usage refresh completed event"); + assert_eq!(event.source, "test-notify"); + assert_eq!(event.processed, 2); + assert_eq!(event.total, 3); + assert!(event.completed_at > 0); +} + /// 函数 `enqueue_usage_refresh_for_same_account_is_deduplicated_until_finish` /// /// 作者: gaohongshun diff --git a/crates/service/src/usage/usage_refresh.rs b/crates/service/src/usage/usage_refresh.rs index c06b12491..164131b2f 100644 --- a/crates/service/src/usage/usage_refresh.rs +++ b/crates/service/src/usage/usage_refresh.rs @@ -7,3 +7,4 @@ pub(crate) use refresh::{ refresh_usage_for_all_accounts, reload_background_tasks_runtime_from_env, set_background_tasks_settings, BackgroundTasksSettingsPatch, }; +pub use refresh::{set_usage_refresh_completed_handler, UsageRefreshCompletedEvent};