Skip to content
Merged
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
99 changes: 98 additions & 1 deletion crates/driven-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ use crate::state::{ActivityLevel, NewActivity, SourceRow, StateRepo};
use crate::time::Clock;
use crate::types::{
AccountId, ExecProgress, OrchestratorEvent, OrchestratorState, PauseReason, PowerEvent,
RelativePath, ScanMode, UnixMs,
RelativePath, ScanMode, ScheduleConfig, UnixMs,
};
use crate::watcher::ScanTickRequest;

Expand Down Expand Up @@ -172,6 +172,12 @@ pub struct OrchestratorConfig {
/// `windows` settings key, wired in by the app shell (M5/M6); the field is
/// here now so the orchestrator honours it.
pub vss_mode: VssMode,
/// Schedule window (V2 schedule windows, DESIGN s17): when
/// [`enabled`](ScheduleConfig::enabled), the gate pauses with
/// [`PauseReason::Schedule`] outside the allowed local-time window and
/// resumes automatically once the clock re-enters it. The
/// [`Default`](ScheduleConfig::default) is disabled (V1 behaviour).
pub schedule: ScheduleConfig,
}

impl Default for OrchestratorConfig {
Expand All @@ -187,6 +193,7 @@ impl Default for OrchestratorConfig {
bandwidth_cap_mbps: None,
pacer_ceilings: PacerCeilings::default(),
vss_mode: VssMode::Auto,
schedule: ScheduleConfig::default(),
}
}
}
Expand Down Expand Up @@ -722,6 +729,15 @@ impl SyncOrchestrator {
return GateDecision::Pause(PauseReason::Battery);
}

// Schedule window (V2 schedule windows, DESIGN s17): pause outside the
// user's allowed local-time window. Reads the injected Clock so the
// decision is deterministic; the gate re-opens on a later cycle once
// the clock re-enters the window (no manual resume needed). A disabled
// schedule always allows, so this is inert under the V1 default.
if !cfg.schedule.allows(self.clock.now_ms()) {
return GateDecision::Pause(PauseReason::Schedule);
}

// Drive circuit breaker (DESIGN s5.8.3): if Drive's breaker is open,
// back off until its half-open probe time rather than hammer a known-
// down dependency.
Expand Down Expand Up @@ -2428,6 +2444,87 @@ mod tests {
);
}

#[tokio::test]
async fn schedule_gate_pauses_outside_window() {
// AC + online + not metered, but the clock (FakeClock starts at epoch
// 1970-01-01 00:00 UTC) is outside a 09:00-17:00 window => Paused{Schedule}.
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 {
schedule: crate::types::ScheduleConfig {
enabled: true,
start_minute: 9 * 60,
end_minute: 17 * 60,
days: [true; 7],
utc_offset_minutes: 0,
},
..OrchestratorConfig::default()
};
let (orch, _clock) = build(
account,
vec![src],
exec.clone(),
power_on_ac(),
Arc::new(FakeNet::online()),
cfg,
);

orch.run_cycle(TickSource::Scheduled).await.unwrap();

assert_eq!(
orch.state().await,
OrchestratorState::Paused {
reason: PauseReason::Schedule
}
);
assert_eq!(
exec.executes.load(Ordering::SeqCst),
0,
"outside the schedule window no plan executes"
);
}

#[tokio::test]
async fn schedule_gate_opens_inside_window() {
// Same window, but advance the clock to 09:00 UTC so the gate opens and
// the cycle proceeds past the schedule gate (no Schedule pause).
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 {
schedule: crate::types::ScheduleConfig {
enabled: true,
start_minute: 9 * 60,
end_minute: 17 * 60,
days: [true; 7],
utc_offset_minutes: 0,
},
..OrchestratorConfig::default()
};
let (orch, clock) = build(
account,
vec![src],
exec.clone(),
power_on_ac(),
Arc::new(FakeNet::online()),
cfg,
);
clock.advance(std::time::Duration::from_secs(9 * 3600)); // -> 09:00 UTC

orch.run_cycle(TickSource::Scheduled).await.unwrap();

assert_ne!(
orch.state().await,
OrchestratorState::Paused {
reason: PauseReason::Schedule
},
"inside the window the schedule gate must not pause"
);
}

#[tokio::test]
async fn ac_resumes_after_battery_pause() {
// Power gate: pause on battery, resume on AC (the two-cycle path).
Expand Down
212 changes: 212 additions & 0 deletions crates/driven-core/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,109 @@ pub enum PauseReason {
/// DNS broken; SPEC s24 `net.dns_failed`). Kept distinct from [`Offline`]
/// per CODEX_NOTES P2-9 (M4).
DnsFailed,
/// Outside the user's configured schedule window (V2 schedule windows,
/// DESIGN s17). The orchestrator resumes automatically once the local
/// clock re-enters the allowed window - no manual action required.
Schedule,
}

// -----------------------------------------------------------------------------
// ScheduleConfig (V2 schedule windows - DESIGN s17)
// -----------------------------------------------------------------------------

/// A time-of-day + day-of-week window during which sync is allowed (V2
/// schedule windows, DESIGN s17 "only sync 23:00-06:00").
///
/// The window is expressed in the user's LOCAL wall-clock time. Like the
/// pacer's "midnight Pacific" quota boundary (see [`crate::pacer`]),
/// `driven-core` stays free of a timezone database: local time is derived
/// from a fixed [`Self::utc_offset_minutes`] the app layer captures from the
/// OS / browser. The bounded consequence is the same as the pacer's - across
/// a DST transition the window shifts by up to an hour until the app
/// re-reads the offset. This is deliberate and documented (DESIGN s17).
///
/// The predicate is a pure function of the injected [`Clock`](crate::time::Clock)
/// reading, so the orchestrator gate is deterministic under `FakeClock`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScheduleConfig {
/// When `false` the schedule never gates (sync runs at any time). This is
/// the V1 behaviour and the [`Default`].
pub enabled: bool,
/// Minutes after local midnight the allowed window opens, `0..=1439`.
pub start_minute: u16,
/// Minutes after local midnight the allowed window closes, `0..=1439`.
///
/// - `end > start`: a same-day window `[start, end)`.
/// - `end < start`: the window wraps past midnight (active `[start, 1440)`
/// and `[0, end)`).
/// - `end == start`: the whole day is allowed (only [`Self::days`] gates).
pub end_minute: u16,
/// Which local days the window is active on, indexed `0 = Sunday ..=
/// 6 = Saturday` to match JavaScript's `Date.getDay()`. The window is
/// evaluated against the CURRENT local day, so a window that wraps past
/// midnight (e.g. 23:00-06:00) needs both the evening day and the
/// following morning's day enabled for the whole window to be allowed.
pub days: [bool; 7],
/// Minutes to ADD to UTC to reach the user's local wall-clock time
/// (e.g. `-480` for PST = UTC-8). The app layer sets this from the OS;
/// the browser value is `-new Date().getTimezoneOffset()`.
pub utc_offset_minutes: i16,
}

impl Default for ScheduleConfig {
/// Disabled: sync runs at any time (V1 behaviour). The window fields are
/// inert while `enabled` is false.
fn default() -> Self {
Self {
enabled: false,
start_minute: 0,
end_minute: 0,
days: [true; 7],
utc_offset_minutes: 0,
}
}
}

impl ScheduleConfig {
/// Milliseconds per minute / minutes per day, for the local-time maths.
const MS_PER_MIN: i64 = 60_000;
const MINS_PER_DAY: i64 = 1_440;

/// True if sync is allowed at the wall-clock instant `now_ms`.
///
/// A disabled schedule always allows. Otherwise the UTC instant is shifted
/// into local wall time by [`Self::utc_offset_minutes`], reduced to a
/// local day-of-week + minute-of-day, and tested against the window. Uses
/// Euclidean division/remainder so a negative (pre-epoch) or
/// backwards-jumped clock reading still yields an in-range day/minute
/// rather than a panic (DESIGN s18.7 - the clock may move backwards).
pub fn allows(&self, now_ms: UnixMs) -> bool {
if !self.enabled {
return true;
}
let local_ms = now_ms.saturating_add((self.utc_offset_minutes as i64) * Self::MS_PER_MIN);
let total_min = local_ms.div_euclid(Self::MS_PER_MIN);
let min_of_day = total_min.rem_euclid(Self::MINS_PER_DAY) as u16;
// Days since the Unix epoch in local time. 1970-01-01 was a Thursday,
// which is `getDay() == 4`, so offset the day count by 4 before the
// mod-7 to land on the Sunday-indexed weekday.
let day_index = total_min.div_euclid(Self::MINS_PER_DAY);
let dow = (day_index + 4).rem_euclid(7) as usize;
if !self.days[dow] {
return false;
}
let (s, e) = (self.start_minute, self.end_minute);
if s == e {
// Whole day allowed; only the day-of-week gates.
return true;
}
if s < e {
min_of_day >= s && min_of_day < e
} else {
// Wraps past midnight.
min_of_day >= s || min_of_day < e
}
}
}

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -1209,4 +1312,113 @@ mod tests {
let rp: RelativePath = std::path::Path::new("a/b.txt").try_into().unwrap();
assert_eq!(rp.as_str(), "a/b.txt");
}

// --- ScheduleConfig (V2 schedule windows) -------------------------------

/// Monday 2024-01-01 00:00:00 UTC, in epoch ms. The dow formula resolves
/// this to `getDay() == 1` (Monday); used as the anchor for the cases
/// below (offsets in minutes/days are added on top).
const MON_2024_01_01_UTC_MS: UnixMs = 1_704_067_200_000;
const MIN_MS: UnixMs = 60_000;
const DAY_MS: UnixMs = 1_440 * MIN_MS;

fn all_days() -> [bool; 7] {
[true; 7]
}

#[test]
fn schedule_disabled_always_allows() {
let s = ScheduleConfig::default();
assert!(!s.enabled);
assert!(s.allows(MON_2024_01_01_UTC_MS));
assert!(s.allows(0));
assert!(s.allows(-1)); // pre-epoch must not panic
}

#[test]
fn schedule_same_day_window_half_open() {
// 09:00-17:00 every day.
let s = ScheduleConfig {
enabled: true,
start_minute: 9 * 60,
end_minute: 17 * 60,
days: all_days(),
utc_offset_minutes: 0,
};
let at = |min: i64| s.allows(MON_2024_01_01_UTC_MS + min * MIN_MS);
assert!(!at(0)); // 00:00 - before
assert!(!at(8 * 60 + 59)); // 08:59 - before
assert!(at(9 * 60)); // 09:00 - open (inclusive)
assert!(at(16 * 60 + 59)); // 16:59 - inside
assert!(!at(17 * 60)); // 17:00 - close (exclusive)
assert!(!at(23 * 60)); // 23:00 - after
}

#[test]
fn schedule_wrap_past_midnight() {
// 23:00-06:00 every day.
let s = ScheduleConfig {
enabled: true,
start_minute: 23 * 60,
end_minute: 6 * 60,
days: all_days(),
utc_offset_minutes: 0,
};
let at = |min: i64| s.allows(MON_2024_01_01_UTC_MS + min * MIN_MS);
assert!(at(23 * 60)); // 23:00 - open
assert!(at(23 * 60 + 30)); // 23:30 - evening tail
assert!(at(0)); // 00:00 - past midnight
assert!(at(5 * 60 + 59)); // 05:59 - morning
assert!(!at(6 * 60)); // 06:00 - close (exclusive)
assert!(!at(12 * 60)); // noon - outside
}

#[test]
fn schedule_equal_bounds_is_whole_day() {
// start == end => only the day-of-week gates.
let s = ScheduleConfig {
enabled: true,
start_minute: 0,
end_minute: 0,
days: all_days(),
utc_offset_minutes: 0,
};
for h in [0, 6, 12, 18, 23] {
assert!(s.allows(MON_2024_01_01_UTC_MS + h * 60 * MIN_MS));
}
}

#[test]
fn schedule_day_of_week_gates() {
// Whole-day window, but only Monday (index 1) enabled.
let mut days = [false; 7];
days[1] = true; // Monday
let s = ScheduleConfig {
enabled: true,
start_minute: 0,
end_minute: 0,
days,
utc_offset_minutes: 0,
};
assert!(s.allows(MON_2024_01_01_UTC_MS)); // Monday
assert!(!s.allows(MON_2024_01_01_UTC_MS + DAY_MS)); // Tuesday
assert!(!s.allows(MON_2024_01_01_UTC_MS - DAY_MS)); // Sunday
assert!(s.allows(MON_2024_01_01_UTC_MS + 7 * DAY_MS)); // next Monday
}

#[test]
fn schedule_utc_offset_shifts_local_time() {
// 00:00-01:00 LOCAL, every day, at UTC+1. 00:00 UTC == 01:00 local,
// which is outside [00:00, 01:00); one hour earlier (23:00 UTC) ==
// 00:00 local, which is inside.
let s = ScheduleConfig {
enabled: true,
start_minute: 0,
end_minute: 60,
days: all_days(),
utc_offset_minutes: 60,
};
assert!(!s.allows(MON_2024_01_01_UTC_MS)); // 01:00 local
assert!(s.allows(MON_2024_01_01_UTC_MS - 60 * MIN_MS)); // 00:00 local
}
}
1 change: 1 addition & 0 deletions src-tauri/locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ tray:
paused_manual: "Driven - paused"
paused_battery: "Driven - paused on battery power"
paused_metered: "Driven - paused on metered network"
paused_schedule: "Driven - paused outside the scheduled window"
offline: "Connected, no Internet"
no_internet: "Connected, no Internet"
captive_portal: "Captive portal - click to sign in"
Expand Down
Loading
Loading