From c7a3a414b0bdf344b06fc16001e1b6241b7baf0b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 17:12:07 +0000 Subject: [PATCH] feat(core): metered network pause-or-throttle toggle Make `skip_on_metered` configurable to either PAUSE (the V1 behaviour) or THROTTLE - keep syncing on a metered network at a reduced bandwidth cap (V2, DESIGN s17). Pacer: the optional bandwidth bucket becomes runtime-swappable via a new `Pacer::set_bandwidth_cap` (a default no-op on the trait, so the six NoopPacer impls need no change; AimdPacer overrides it). The bucket + its effective rate live behind one `Mutex`; `permit_bytes` clones the bucket Arc out under a brief lock so the async acquire never holds the lock across an await, and a concurrent cap swap is safe. set_bandwidth_cap is idempotent. Install / lift / idempotency are tested. Orchestrator: holds the executor's pacer (shared via a new `with_pacer` builder, no-op default like `with_command_runner`). When the gates are open it applies the effective cap for the current network: on a metered network in Throttle mode the metered cap, otherwise the base cap. The metered gate no longer pauses in Throttle mode. A pure `effective_bandwidth_cap_mbps` + two gate tests (throttle caps + does not pause; pause still pauses + skips the cap) cover it. Settings: `metered_mode` (pause|throttle) + `metered_bandwidth_cap_mbps` threaded through the SPEC s22 `global` group; assembly shares the pacer. UI: a metered mode selector (shown when "limit on metered" is on) + a throttle bandwidth cap input, with i18n and a component test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WvXMHHbYGddVPpmQR2XmK1 --- crates/driven-core/src/orchestrator.rs | 210 ++++++++++++++++++- crates/driven-core/src/pacer.rs | 125 +++++++++-- src-tauri/src/assembly.rs | 8 +- src-tauri/src/commands/dtos.rs | 9 + src-tauri/src/commands/settings.rs | 42 +++- ui/src/__tests__/settings-components.test.ts | 45 ++++ ui/src/__tests__/settings-stores.test.ts | 2 + ui/src/ipc/types.ts | 6 + ui/src/locales/en-US.json | 10 +- ui/src/views/Settings.vue | 54 +++++ 10 files changed, 490 insertions(+), 21 deletions(-) diff --git a/crates/driven-core/src/orchestrator.rs b/crates/driven-core/src/orchestrator.rs index c5e3ebb7..5b6b424f 100644 --- a/crates/driven-core/src/orchestrator.rs +++ b/crates/driven-core/src/orchestrator.rs @@ -57,7 +57,7 @@ use driven_vss::{VssMode, VssProvider}; use crate::executor::{Executor, OpOutcome}; use crate::hooks::{CommandRunner, HookKind, NoopCommandRunner}; use crate::network::{NetworkProbe, NetworkState, ServiceHealth, ServiceName}; -use crate::pacer::PacerCeilings; +use crate::pacer::{Pacer, PacerCeilings}; use crate::state::{ActivityLevel, NewActivity, SourceRow, StateRepo}; use crate::time::Clock; use crate::types::{ @@ -189,6 +189,40 @@ pub struct OrchestratorConfig { pub post_backup_hook: Option, /// How long a hook command may run before it is killed, in seconds. pub hook_timeout_secs: u32, + /// What [`skip_on_metered`](Self::skip_on_metered) DOES on a metered + /// network (V2 metered pause-or-throttle, DESIGN s17): fully pause + /// (default, V1 behaviour) or keep syncing at a reduced bandwidth cap. + pub metered_mode: MeteredMode, + /// The bandwidth cap (Mbps) applied while metered in + /// [`MeteredMode::Throttle`]. `None` falls back to the normal + /// [`bandwidth_cap_mbps`](Self::bandwidth_cap_mbps). + pub metered_bandwidth_cap_mbps: Option, +} + +/// What Driven does on a metered network when +/// [`skip_on_metered`](OrchestratorConfig::skip_on_metered) is on (V2, DESIGN +/// s17). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum MeteredMode { + /// Pause sync entirely while metered (the V1 behaviour). + #[default] + Pause, + /// Keep syncing but cap bandwidth at + /// [`metered_bandwidth_cap_mbps`](OrchestratorConfig::metered_bandwidth_cap_mbps). + Throttle, +} + +/// The effective bandwidth cap (Mbps) for the current network (V2 metered +/// throttle, DESIGN s17). On a metered network with `skip_on_metered` on and +/// [`MeteredMode::Throttle`], the metered cap applies (falling back to the base +/// cap if unset); otherwise the base cap. Pure, for testability. +fn effective_bandwidth_cap_mbps(cfg: &OrchestratorConfig, on_metered: bool) -> Option { + if on_metered && cfg.skip_on_metered && cfg.metered_mode == MeteredMode::Throttle { + cfg.metered_bandwidth_cap_mbps.or(cfg.bandwidth_cap_mbps) + } else { + cfg.bandwidth_cap_mbps + } } impl Default for OrchestratorConfig { @@ -208,6 +242,8 @@ impl Default for OrchestratorConfig { pre_backup_hook: None, post_backup_hook: None, hook_timeout_secs: 60, + metered_mode: MeteredMode::Pause, + metered_bandwidth_cap_mbps: None, } } } @@ -373,6 +409,11 @@ pub struct SyncOrchestrator { /// [`Self::with_command_runner`]. The hook COMMANDS come from /// [`OrchestratorConfig`]; this is only the seam that runs them. command_runner: Arc, + /// The executor's rate pacer, shared in for the V2 metered throttle + /// (DESIGN s17): the orchestrator lowers / lifts its bandwidth cap as the + /// network goes on / off metered. `None` (the default / tests) disables the + /// runtime throttle; the cap then stays at its construction value. + pacer: Option>, /// Per-orchestrator record-at-create ledger (P1-A). The recorder hook wired /// into the provider by [`Self::with_vss`] pushes each freshly-created /// shadow GUID here synchronously; `record_vss_orphans` drains it into the @@ -438,6 +479,7 @@ impl SyncOrchestrator { shutdown_rx, vss: None, command_runner: Arc::new(NoopCommandRunner), + pacer: None, vss_create_ledger: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), orphan_cleanup_done: Mutex::new(false), suspended: std::sync::atomic::AtomicBool::new(false), @@ -474,6 +516,28 @@ impl SyncOrchestrator { self } + /// Share in the executor's [`Pacer`] so the orchestrator can drive the V2 + /// metered throttle (DESIGN s17). Pass the SAME `Arc` the executor holds so + /// a runtime cap change is seen by the upload path. Without this the metered + /// throttle is inert (the pacer keeps its construction-time cap). + pub fn with_pacer(mut self, pacer: Arc) -> Self { + self.pacer = Some(pacer); + self + } + + /// Apply the effective bandwidth cap for the current network to the shared + /// pacer (V2 metered throttle, DESIGN s17). On a metered network in + /// [`MeteredMode::Throttle`] the metered cap applies; otherwise the normal + /// cap. A no-op when no pacer was shared in. Idempotent (the pacer ignores + /// an unchanged rate), so it is safe to call every cycle. + async fn apply_bandwidth_cap(&self) { + let Some(pacer) = &self.pacer else { return }; + let cfg = self.config.read().await; + let on_metered = self.power.current().await.on_metered_network; + let mbps = effective_bandwidth_cap_mbps(&cfg, on_metered); + pacer.set_bandwidth_cap(mbps.map(f64::from)); + } + /// Run a configured pre/post backup hook command and record the outcome as /// an activity row. Returns whether the command SUCCEEDED (clean zero /// exit). Passes `DRIVEN_HOOK` (`pre`/`post`), `DRIVEN_ACCOUNT_ID`, and - @@ -797,8 +861,11 @@ impl SyncOrchestrator { return GateDecision::Pause(pause_reason_for_network(net)); } - // Metered network (DESIGN s5.7): pause if configured. - if cfg.skip_on_metered && power.on_metered_network { + // Metered network (DESIGN s5.7): in Pause mode pause; in Throttle mode + // (V2, DESIGN s17) keep syncing - the reduced cap is applied to the + // pacer in `apply_bandwidth_cap` before the source loop. + if cfg.skip_on_metered && power.on_metered_network && cfg.metered_mode == MeteredMode::Pause + { return GateDecision::Pause(PauseReason::Metered); } @@ -1463,6 +1530,12 @@ impl SyncOrchestrator { GateDecision::Proceed => {} } + // V2 metered throttle (DESIGN s17): the gates are open, so apply the + // effective bandwidth cap for the current network to the shared pacer + // before any upload. On a metered network in Throttle mode this lowers + // the cap; off metered it lifts it back to the base cap. Idempotent. + self.apply_bandwidth_cap().await; + // Remote reconcile phase (DESIGN s5.6): now that the gates are open we // may safely issue Drive calls. Guarded to run at most once before the // first executing cycle. @@ -2644,6 +2717,137 @@ mod tests { .any(|(k, v)| k == "DRIVEN_RESULT" && v == "ok")); } + /// Records the last `set_bandwidth_cap` argument; the rate gates are no-ops. + #[derive(Default)] + struct FakePacer { + last_cap: StdMutex>>, + } + + #[async_trait] + impl Pacer for FakePacer { + async fn permit_request(&self) {} + async fn permit_file_create(&self) {} + async fn permit_bytes(&self, _n: u64) {} + fn note_response(&self, _c: crate::pacer::ResponseClass) {} + fn ceilings(&self) -> PacerCeilings { + PacerCeilings::default() + } + fn set_bandwidth_cap(&self, mbps: Option) { + *self.last_cap.lock().unwrap() = Some(mbps); + } + } + + fn power_on_metered() -> PowerState { + PowerState { + ac_connected: true, + battery_percent: Some(100), + on_metered_network: true, + network_reachable: true, + } + } + + #[test] + fn effective_cap_throttles_only_when_metered_and_throttle_mode() { + let base = OrchestratorConfig { + bandwidth_cap_mbps: Some(100), + skip_on_metered: true, + metered_bandwidth_cap_mbps: Some(2), + ..OrchestratorConfig::default() + }; + let throttle = OrchestratorConfig { + metered_mode: MeteredMode::Throttle, + ..base.clone() + }; + // Off metered -> base cap, regardless of mode. + assert_eq!(effective_bandwidth_cap_mbps(&throttle, false), Some(100)); + // Metered + throttle -> the metered cap. + assert_eq!(effective_bandwidth_cap_mbps(&throttle, true), Some(2)); + // Metered + pause -> base cap (it will be paused anyway, not throttled). + assert_eq!(effective_bandwidth_cap_mbps(&base, true), Some(100)); + // Metered + throttle but no metered cap -> falls back to base. + let no_cap = OrchestratorConfig { + metered_bandwidth_cap_mbps: None, + ..throttle.clone() + }; + assert_eq!(effective_bandwidth_cap_mbps(&no_cap, true), Some(100)); + } + + #[tokio::test] + async fn metered_throttle_does_not_pause_and_caps_the_pacer() { + let account = AccountId::new_v4(); + let dir = tempfile::tempdir().unwrap(); + let src = source_in(account, dir.path()); + let exec = Arc::new(RecordingExecutor::default()); + let cfg = OrchestratorConfig { + skip_on_metered: true, + metered_mode: MeteredMode::Throttle, + metered_bandwidth_cap_mbps: Some(2), + ..OrchestratorConfig::default() + }; + let (orch, _clock) = build( + account, + vec![src], + exec.clone(), + power_on_metered(), + Arc::new(FakeNet::online()), + cfg, + ); + let pacer = Arc::new(FakePacer::default()); + let orch = orch.with_pacer(pacer.clone()); + + orch.run_cycle(TickSource::Scheduled).await.unwrap(); + + assert_ne!( + orch.state().await, + OrchestratorState::Paused { + reason: PauseReason::Metered + }, + "throttle mode must not pause on a metered network" + ); + assert_eq!( + *pacer.last_cap.lock().unwrap(), + Some(Some(2.0)), + "the metered cap (2 Mbps) was applied to the pacer" + ); + } + + #[tokio::test] + async fn metered_pause_mode_still_pauses_and_skips_the_cap() { + let account = AccountId::new_v4(); + let dir = tempfile::tempdir().unwrap(); + let src = source_in(account, dir.path()); + let exec = Arc::new(RecordingExecutor::default()); + let cfg = OrchestratorConfig { + skip_on_metered: true, + metered_mode: MeteredMode::Pause, + ..OrchestratorConfig::default() + }; + let (orch, _clock) = build( + account, + vec![src], + exec.clone(), + power_on_metered(), + Arc::new(FakeNet::online()), + cfg, + ); + let pacer = Arc::new(FakePacer::default()); + let orch = orch.with_pacer(pacer.clone()); + + orch.run_cycle(TickSource::Scheduled).await.unwrap(); + + assert_eq!( + orch.state().await, + OrchestratorState::Paused { + reason: PauseReason::Metered + } + ); + assert_eq!( + *pacer.last_cap.lock().unwrap(), + None, + "pause mode pauses before applying any cap" + ); + } + #[tokio::test] async fn battery_gate_pauses_when_skip_on_battery() { // On battery with skip_on_battery => Paused{Battery}, no execute. diff --git a/crates/driven-core/src/pacer.rs b/crates/driven-core/src/pacer.rs index 16603bd4..651dcd43 100644 --- a/crates/driven-core/src/pacer.rs +++ b/crates/driven-core/src/pacer.rs @@ -138,6 +138,13 @@ pub trait Pacer: Send + Sync { /// Returns the current ceilings snapshot (for the diagnostics bundle /// and the "current rate" status read-out). fn ceilings(&self) -> PacerCeilings; + + /// Set the effective bandwidth cap at runtime, in Mbps (`None` = + /// unlimited). Drives the V2 metered-network throttle (DESIGN s17): the + /// orchestrator lowers the cap on a metered network and lifts it off one. + /// The default is a no-op so simple test/fake pacers need not implement it; + /// [`AimdPacer`] overrides it. + fn set_bandwidth_cap(&self, _mbps: Option) {} } /// `serde` helper: (de)serialise a [`Duration`] as integer milliseconds so @@ -368,12 +375,25 @@ impl TokenBucket { /// the optional bandwidth bucket, the AIMD ceilings, a backoff deadline, /// and the clean-window timer. All time decisions read the injected /// [`Clock`] so tests drive AIMD deterministically with a `FakeClock`. +/// The optional bandwidth gate, mutable at runtime (V2 metered throttle). +/// +/// `rate` is the current effective refill rate (bytes/s), `None` = unlimited. +/// `bucket` is the live [`TokenBucket`] held behind an `Arc` so +/// [`AimdPacer::permit_bytes`] can clone it out under a brief lock and run the +/// async `acquire` WITHOUT holding the gate lock across the await. +/// [`Pacer::set_bandwidth_cap`] swaps both atomically when the cap changes. +struct ByteGate { + rate: Option, + bucket: Option>, +} + pub struct AimdPacer { qps_bucket: TokenBucket, file_bucket: TokenBucket, - /// `Some` when `settings.bandwidth_cap_mbps` is set; `None` bypasses - /// the byte gate entirely (SPEC s9). - bytes_bucket: Option, + /// The optional bandwidth gate (SPEC s9), swappable at runtime so the + /// metered-network throttle (V2, DESIGN s17) can lower / lift the cap + /// without rebuilding the pacer. `None` rate bypasses the byte gate. + bytes: Mutex, /// Wall-clock deadline (ms) before which `permit_request` sleeps /// (SPEC s9 `backoff_until`). `<= now` means no backoff. backoff_until_ms: AtomicI64, @@ -383,9 +403,6 @@ pub struct AimdPacer { clean_window_start_ms: AtomicI64, /// Bit-packed current ceilings, guarded for atomic snapshot/update. ceilings: Mutex, - /// Bandwidth refill rate captured at construction so a daily re-init - /// can rebuild the byte bucket identically (it is independent of AIMD). - bytes_rate_per_sec: Option, clock: Arc, } @@ -394,14 +411,14 @@ pub struct AimdPacer { // `TokenBucket` Debug impl above). impl std::fmt::Debug for AimdPacer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let bytes_rate = lock_recover(&self.bytes).rate; f.debug_struct("AimdPacer") .field("qps_bucket", &self.qps_bucket) .field("file_bucket", &self.file_bucket) - .field("bytes_bucket", &self.bytes_bucket) + .field("bytes_rate_per_sec", &bytes_rate) .field("backoff_until_ms", &self.backoff_until_ms) .field("clean_window_start_ms", &self.clean_window_start_ms) .field("ceilings", &self.ceilings) - .field("bytes_rate_per_sec", &self.bytes_rate_per_sec) .finish_non_exhaustive() } } @@ -439,21 +456,28 @@ impl AimdPacer { let bytes_rate_per_sec = bandwidth_cap_mbps.map(|mbps| mbps * 1_000_000.0 / 8.0); let bytes_bucket = bytes_rate_per_sec.map(|rate| { // Burst = 2x refill (SPEC s9). - TokenBucket::new(rate, rate * 2.0, clock.clone()) + Arc::new(TokenBucket::new(rate, rate * 2.0, clock.clone())) }); let now = clock.now_ms(); Self { qps_bucket, file_bucket, - bytes_bucket, + bytes: Mutex::new(ByteGate { + rate: bytes_rate_per_sec, + bucket: bytes_bucket, + }), backoff_until_ms: AtomicI64::new(now), clean_window_start_ms: AtomicI64::new(now), ceilings: Mutex::new(ceilings), - bytes_rate_per_sec, clock, } } + /// The bytes/sec refill rate for a `bandwidth_cap_mbps` setting (SPEC s9). + fn mbps_to_bytes_per_sec(mbps: f64) -> f64 { + mbps * 1_000_000.0 / 8.0 + } + /// Sleeps out any active backoff window, polling the wall clock so a /// `FakeClock` advance lifts it deterministically. async fn wait_out_backoff(&self) { @@ -582,8 +606,11 @@ impl AimdPacer { default.file_creates_per_sec as f64, (default.file_creates_per_sec as f64) * 2.0, ); - if let (Some(b), Some(rate)) = (&self.bytes_bucket, self.bytes_rate_per_sec) { - b.reconfigure(rate, rate * 2.0); + { + let gate = lock_recover(&self.bytes); + if let (Some(b), Some(rate)) = (&gate.bucket, gate.rate) { + b.reconfigure(rate, rate * 2.0); + } } self.clean_window_start_ms.store(now, Ordering::Release); } @@ -604,12 +631,30 @@ impl Pacer for AimdPacer { } async fn permit_bytes(&self, n: u64) { - // `None` = unlimited / bypassed (SPEC s9): the gate is a no-op. - if let Some(bucket) = &self.bytes_bucket { + // Clone the live bucket out under a BRIEF lock so the async `acquire` + // never holds the gate lock across an await (and a concurrent + // `set_bandwidth_cap` can swap it). A `None` bucket = unlimited no-op. + let bucket = lock_recover(&self.bytes).bucket.clone(); + if let Some(bucket) = bucket { bucket.acquire(n).await; } } + fn set_bandwidth_cap(&self, mbps: Option) { + // V2 metered throttle (DESIGN s17): swap the effective cap at runtime. + // Idempotent so the orchestrator may call it every cycle. A daily-quota + // re-init rebuilds the bucket from `rate`, so updating `rate` keeps the + // two consistent. + let new_rate = mbps.map(Self::mbps_to_bytes_per_sec); + let mut gate = lock_recover(&self.bytes); + if gate.rate == new_rate { + return; + } + gate.rate = new_rate; + gate.bucket = + new_rate.map(|rate| Arc::new(TokenBucket::new(rate, rate * 2.0, self.clock.clone()))); + } + fn note_response(&self, classification: ResponseClass) { let now = self.clock.now_ms(); match classification { @@ -873,6 +918,56 @@ mod tests { assert_eq!(fake.now_ms(), 0, "no-op acquire did not block"); } + #[tokio::test] + async fn set_bandwidth_cap_installs_a_cap_at_runtime() { + let (fake, c) = clock(); + let pacer = Arc::new(AimdPacer::new(c, None)); // starts unlimited + pacer.permit_bytes(u64::MAX).await; + assert_eq!(fake.now_ms(), 0, "no cap yet: instant"); + + // Install a 1 Mbps cap (125_000 B/s, burst 250_000) at runtime. + pacer.set_bandwidth_cap(Some(1.0)); + pacer.permit_bytes(250_000).await; // drains the fresh burst, still t=0 + let p2 = pacer.clone(); + drive(&fake, Duration::from_millis(50), async move { + p2.permit_bytes(125_000).await; + }) + .await; + assert!( + fake.now_ms() >= 900, + "throttled once the cap was installed, clock at {}", + fake.now_ms() + ); + } + + #[tokio::test] + async fn set_bandwidth_cap_lifts_the_cap() { + let (fake, c) = clock(); + let pacer = Arc::new(AimdPacer::new(c, Some(1.0))); // starts capped + pacer.set_bandwidth_cap(None); // lift the cap + pacer.permit_bytes(u64::MAX).await; + assert_eq!(fake.now_ms(), 0, "cap lifted: acquire is now a no-op"); + } + + #[tokio::test] + async fn set_bandwidth_cap_is_idempotent_for_the_same_rate() { + let (fake, c) = clock(); + let pacer = Arc::new(AimdPacer::new(c, Some(1.0))); + pacer.permit_bytes(250_000).await; // drain the burst at t=0 + // Re-applying the SAME cap must NOT rebuild (and refill) the bucket. + pacer.set_bandwidth_cap(Some(1.0)); + let p2 = pacer.clone(); + drive(&fake, Duration::from_millis(50), async move { + p2.permit_bytes(125_000).await; + }) + .await; + assert!( + fake.now_ms() >= 900, + "idempotent re-apply kept the drained bucket, clock at {}", + fake.now_ms() + ); + } + #[tokio::test] async fn daily_quota_pauses_until_midnight_and_reinits() { let (fake, c) = clock(); diff --git a/src-tauri/src/assembly.rs b/src-tauri/src/assembly.rs index 818bf4d9..de26d26f 100644 --- a/src-tauri/src/assembly.rs +++ b/src-tauri/src/assembly.rs @@ -434,7 +434,10 @@ async fn build_account( ExecutorDeps { remote, state: state.clone(), - pacer, + // Clone so the orchestrator can share the SAME pacer for the V2 + // metered throttle (`with_pacer` below) - a runtime cap change must + // be seen by this executor's upload path. + pacer: pacer.clone(), crypto: Some(crypto_dyn), vss: vss.clone(), network: Some(network.clone()), @@ -466,6 +469,9 @@ async fn build_account( // orchestrator keeps the inert no-op runner and configured hooks never run. orchestrator = orchestrator.with_command_runner(Arc::new(crate::hook_runner::TokioCommandRunner)); + // Share the executor's pacer so the V2 metered throttle (DESIGN s17) can + // lower / lift its bandwidth cap as the network goes on / off metered. + orchestrator = orchestrator.with_pacer(pacer); let orchestrator = Arc::new(orchestrator); // R-P1-1: one shutdown signal both bridges select! on, so quit can stop the diff --git a/src-tauri/src/commands/dtos.rs b/src-tauri/src/commands/dtos.rs index 8831c4cd..25107d25 100644 --- a/src-tauri/src/commands/dtos.rs +++ b/src-tauri/src/commands/dtos.rs @@ -383,6 +383,11 @@ pub struct GlobalSettings { pub post_backup_hook: Option, /// How long a hook command may run before it is killed, in seconds. pub hook_timeout_secs: u32, + /// V2 metered pause-or-throttle: `pause` | `throttle` (DESIGN s17). + pub metered_mode: String, + /// Bandwidth cap (Mbps) used while metered in `throttle` mode; `null` + /// falls back to `bandwidthCapMbps`. + pub metered_bandwidth_cap_mbps: Option, } /// V2 schedule-window settings (DESIGN s17). Mirrors @@ -496,6 +501,10 @@ pub struct GlobalSettingsPatch { pub post_backup_hook: Option>, /// See [`GlobalSettings::hook_timeout_secs`]. pub hook_timeout_secs: Option, + /// See [`GlobalSettings::metered_mode`]. + pub metered_mode: Option, + /// See [`GlobalSettings::metered_bandwidth_cap_mbps`]. `Some(None)` clears it. + pub metered_bandwidth_cap_mbps: Option>, } /// Partial SPEC s22 `telemetry` settings. diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index e67cd302..a5a93f34 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -31,7 +31,7 @@ use std::path::PathBuf; use serde::Deserialize; use tauri::{AppHandle, State}; -use driven_core::orchestrator::OrchestratorConfig; +use driven_core::orchestrator::{MeteredMode, OrchestratorConfig}; use driven_core::state::StateRepo; use driven_core::types::ErrorCode; @@ -289,6 +289,23 @@ pub async fn update_settings( cur.hook_timeout_secs = v; orchestrator_affecting = true; } + if let Some(v) = g.metered_mode { + check_enum("metered_mode", &v, METERED_MODES)?; + cur.metered_mode = v; + orchestrator_affecting = true; + } + if let Some(v) = g.metered_bandwidth_cap_mbps { + if let Some(n) = v { + check_range( + "metered_bandwidth_cap_mbps", + n, + BANDWIDTH_CAP_MIN, + BANDWIDTH_CAP_MAX, + )?; + } + cur.metered_bandwidth_cap_mbps = v; + orchestrator_affecting = true; + } store_group(repo, KEY_GLOBAL, &storage::Global::from(cur)).await?; } @@ -430,6 +447,8 @@ const UPDATE_CHECK_MAX: u32 = 604_800; /// Valid `io_priority` values (SPEC s22). const IO_PRIORITIES: &[&str] = &["normal", "low", "idle"]; +/// V2 metered pause-or-throttle modes (DESIGN s17). +const METERED_MODES: &[&str] = &["pause", "throttle"]; /// Valid `log_level` values (the `tracing` levels). const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace"]; /// Valid updater `channel` values (SPEC s22). @@ -608,6 +627,10 @@ mod storage { pub post_backup_hook: Option, #[serde(default = "default_hook_timeout_secs")] pub hook_timeout_secs: u32, + #[serde(default = "default_metered_mode")] + pub metered_mode: String, + #[serde(default)] + pub metered_bandwidth_cap_mbps: Option, } /// Default hook timeout (seconds) for a pre-V2 `global` blob missing it. @@ -615,6 +638,11 @@ mod storage { 60 } + /// Default metered mode (V1 behaviour: pause) for a pre-V2 `global` blob. + fn default_metered_mode() -> String { + "pause".to_string() + } + impl From for GlobalSettings { fn from(s: Global) -> Self { GlobalSettings { @@ -631,6 +659,8 @@ mod storage { pre_backup_hook: s.pre_backup_hook, post_backup_hook: s.post_backup_hook, hook_timeout_secs: s.hook_timeout_secs, + metered_mode: s.metered_mode, + metered_bandwidth_cap_mbps: s.metered_bandwidth_cap_mbps, } } } @@ -651,6 +681,8 @@ mod storage { pre_backup_hook: d.pre_backup_hook, post_backup_hook: d.post_backup_hook, hook_timeout_secs: d.hook_timeout_secs, + metered_mode: d.metered_mode, + metered_bandwidth_cap_mbps: d.metered_bandwidth_cap_mbps, } } } @@ -867,6 +899,12 @@ pub async fn load_orchestrator_config(state: &dyn StateRepo) -> CommandResult GlobalSettings { pre_backup_hook: None, post_backup_hook: None, hook_timeout_secs: 60, + metered_mode: "pause".to_string(), + metered_bandwidth_cap_mbps: None, } } diff --git a/ui/src/__tests__/settings-components.test.ts b/ui/src/__tests__/settings-components.test.ts index 89288004..6800ec96 100644 --- a/ui/src/__tests__/settings-components.test.ts +++ b/ui/src/__tests__/settings-components.test.ts @@ -82,6 +82,8 @@ function makeSettings(over: Partial = {}): SettingsDto { preBackupHook: null, postBackupHook: null, hookTimeoutSecs: 60, + meteredMode: "pause", + meteredBandwidthCapMbps: null, }, telemetry: { enabled: true, @@ -647,6 +649,49 @@ describe("Settings Rules tab", () => { expect(lastSchedule()?.days[0]).toBe(false); }); + it("metered: switching to throttle patches the mode and reveals the cap input", async () => { + // Deep-merge the global on round-trip so the metered section (gated on + // skipOnMetered) stays rendered after the mode patch. + invokeMock.mockImplementation((cmd: string, args: unknown) => { + if (cmd === "get_settings") return Promise.resolve(makeSettings()); + if (cmd === "update_settings") { + const patch = (args as { patch: { global?: Record } }).patch; + const base = makeSettings(); + return Promise.resolve({ + ...base, + global: { ...base.global, ...(patch.global ?? {}) }, + }); + } + return Promise.resolve(undefined); + }); + + const wrapper = mount(Settings, { + props: { tab: "rules" }, + global: globalMountOptions, + }); + await flushPromises(); + + // In pause mode the throttle cap input is hidden. + expect(wrapper.find('[data-testid="metered-setting"] input[type="number"]').exists()).toBe( + false + ); + + await wrapper.get('[data-testid="metered-mode"]').setValue("throttle"); + await flushPromises(); + expect(invokeMock).toHaveBeenCalledWith("update_settings", { + patch: { global: { meteredMode: "throttle" } }, + }); + + // The cap input now appears; setting it patches the metered cap. + const cap = wrapper.get('[data-testid="metered-setting"] input[type="number"]'); + await cap.setValue("5"); + await cap.trigger("change"); + await flushPromises(); + expect(invokeMock).toHaveBeenCalledWith("update_settings", { + patch: { global: { meteredBandwidthCapMbps: 5 } }, + }); + }); + it("backup hooks: setting a command patches it, clearing patches null", async () => { invokeMock.mockImplementation((cmd: string, args: unknown) => { if (cmd === "get_settings") return Promise.resolve(makeSettings()); diff --git a/ui/src/__tests__/settings-stores.test.ts b/ui/src/__tests__/settings-stores.test.ts index 6fcfff12..a78239cc 100644 --- a/ui/src/__tests__/settings-stores.test.ts +++ b/ui/src/__tests__/settings-stores.test.ts @@ -77,6 +77,8 @@ function makeSettings(over: Partial = {}): SettingsDto { preBackupHook: null, postBackupHook: null, hookTimeoutSecs: 60, + meteredMode: "pause", + meteredBandwidthCapMbps: null, }, telemetry: { enabled: true, diff --git a/ui/src/ipc/types.ts b/ui/src/ipc/types.ts index 4ed95e46..d3ef5d3b 100644 --- a/ui/src/ipc/types.ts +++ b/ui/src/ipc/types.ts @@ -180,6 +180,10 @@ export interface GlobalSettings { postBackupHook: string | null; /** How long a hook may run before it is killed, in seconds. */ hookTimeoutSecs: number; + /** V2 metered behaviour: "pause" | "throttle". */ + meteredMode: string; + /** Bandwidth cap (Mbps) while metered in throttle mode; null falls back. */ + meteredBandwidthCapMbps: number | null; } export interface TelemetrySettings { @@ -227,6 +231,8 @@ export interface GlobalSettingsPatch { preBackupHook?: string | null; postBackupHook?: string | null; hookTimeoutSecs?: number; + meteredMode?: string; + meteredBandwidthCapMbps?: number | null; } export interface TelemetrySettingsPatch { diff --git a/ui/src/locales/en-US.json b/ui/src/locales/en-US.json index 1de3b4c3..f9260083 100644 --- a/ui/src/locales/en-US.json +++ b/ui/src/locales/en-US.json @@ -144,7 +144,15 @@ "rules": { "title": "Global rules", "skipOnBatteryLabel": "Pause backups while on battery", - "skipOnMeteredLabel": "Pause backups on metered networks", + "skipOnMeteredLabel": "Limit backups on metered networks", + "metered": { + "modeLabel": "On a metered network", + "mode": { + "pause": "Pause backups", + "throttle": "Keep backing up, but slower" + }, + "capLabel": "Metered bandwidth cap (Mbps)" + }, "bandwidthCapLabel": "Bandwidth cap (Mbps)", "bandwidthCapUnlimited": "Unlimited", "concurrentUploadsLabel": "Concurrent uploads", diff --git a/ui/src/views/Settings.vue b/ui/src/views/Settings.vue index b5759865..dc3418a5 100644 --- a/ui/src/views/Settings.vue +++ b/ui/src/views/Settings.vue @@ -50,6 +50,11 @@ const preBackupHook = ref(""); const postBackupHook = ref(""); const hookTimeoutSecs = ref(60); +// Metered pause-or-throttle local mirrors (DESIGN s17). +const meteredModes = ["pause", "throttle"] as const; +const meteredMode = ref("pause"); +const meteredCapText = ref(""); + function minutesToHHMM(min: number): string { const m = ((Math.floor(min) % 1440) + 1440) % 1440; const hh = String(Math.floor(m / 60)).padStart(2, "0"); @@ -102,6 +107,9 @@ watch( preBackupHook.value = s.global.preBackupHook ?? ""; postBackupHook.value = s.global.postBackupHook ?? ""; hookTimeoutSecs.value = s.global.hookTimeoutSecs ?? 60; + meteredMode.value = s.global.meteredMode ?? "pause"; + meteredCapText.value = + s.global.meteredBandwidthCapMbps === null ? "" : String(s.global.meteredBandwidthCapMbps); }, { immediate: true } ); @@ -133,6 +141,17 @@ async function setSkipOnMetered(event: Event): Promise { await settings.patch({ global: { skipOnMetered: checked } }); } +async function setMeteredMode(event: Event): Promise { + const value = (event.target as HTMLSelectElement).value; + await settings.patch({ global: { meteredMode: value } }); +} + +async function commitMeteredCap(): Promise { + await settings.patch({ + global: { meteredBandwidthCapMbps: parseOptionalPositiveInt(meteredCapText.value) }, + }); +} + async function commitBandwidthCap(): Promise { await settings.patch({ global: { bandwidthCapMbps: parseOptionalPositiveInt(bandwidthCapText.value) }, @@ -283,6 +302,41 @@ async function setTelemetryEnabled(event: Event): Promise { {{ t("settings.rules.skipOnMeteredLabel") }} +
+ + +
+