Skip to content

Commit 3de2cf7

Browse files
authored
Merge pull request #12 from pmaxhogan/claude/feat-schedule-windows
feat(core): schedule windows (time-of-day backup gating)
2 parents 00533de + 89afd39 commit 3de2cf7

11 files changed

Lines changed: 662 additions & 7 deletions

File tree

crates/driven-core/src/orchestrator.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ use crate::state::{ActivityLevel, NewActivity, SourceRow, StateRepo};
6161
use crate::time::Clock;
6262
use crate::types::{
6363
AccountId, ExecProgress, OrchestratorEvent, OrchestratorState, PauseReason, PowerEvent,
64-
RelativePath, ScanMode, UnixMs,
64+
RelativePath, ScanMode, ScheduleConfig, UnixMs,
6565
};
6666
use crate::watcher::ScanTickRequest;
6767

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

177183
impl Default for OrchestratorConfig {
@@ -187,6 +193,7 @@ impl Default for OrchestratorConfig {
187193
bandwidth_cap_mbps: None,
188194
pacer_ceilings: PacerCeilings::default(),
189195
vss_mode: VssMode::Auto,
196+
schedule: ScheduleConfig::default(),
190197
}
191198
}
192199
}
@@ -722,6 +729,15 @@ impl SyncOrchestrator {
722729
return GateDecision::Pause(PauseReason::Battery);
723730
}
724731

732+
// Schedule window (V2 schedule windows, DESIGN s17): pause outside the
733+
// user's allowed local-time window. Reads the injected Clock so the
734+
// decision is deterministic; the gate re-opens on a later cycle once
735+
// the clock re-enters the window (no manual resume needed). A disabled
736+
// schedule always allows, so this is inert under the V1 default.
737+
if !cfg.schedule.allows(self.clock.now_ms()) {
738+
return GateDecision::Pause(PauseReason::Schedule);
739+
}
740+
725741
// Drive circuit breaker (DESIGN s5.8.3): if Drive's breaker is open,
726742
// back off until its half-open probe time rather than hammer a known-
727743
// down dependency.
@@ -2428,6 +2444,87 @@ mod tests {
24282444
);
24292445
}
24302446

2447+
#[tokio::test]
2448+
async fn schedule_gate_pauses_outside_window() {
2449+
// AC + online + not metered, but the clock (FakeClock starts at epoch
2450+
// 1970-01-01 00:00 UTC) is outside a 09:00-17:00 window => Paused{Schedule}.
2451+
let account = AccountId::new_v4();
2452+
let dir = tempfile::tempdir().unwrap();
2453+
let src = source_in(account, dir.path());
2454+
let exec = Arc::new(RecordingExecutor::default());
2455+
let cfg = OrchestratorConfig {
2456+
schedule: crate::types::ScheduleConfig {
2457+
enabled: true,
2458+
start_minute: 9 * 60,
2459+
end_minute: 17 * 60,
2460+
days: [true; 7],
2461+
utc_offset_minutes: 0,
2462+
},
2463+
..OrchestratorConfig::default()
2464+
};
2465+
let (orch, _clock) = build(
2466+
account,
2467+
vec![src],
2468+
exec.clone(),
2469+
power_on_ac(),
2470+
Arc::new(FakeNet::online()),
2471+
cfg,
2472+
);
2473+
2474+
orch.run_cycle(TickSource::Scheduled).await.unwrap();
2475+
2476+
assert_eq!(
2477+
orch.state().await,
2478+
OrchestratorState::Paused {
2479+
reason: PauseReason::Schedule
2480+
}
2481+
);
2482+
assert_eq!(
2483+
exec.executes.load(Ordering::SeqCst),
2484+
0,
2485+
"outside the schedule window no plan executes"
2486+
);
2487+
}
2488+
2489+
#[tokio::test]
2490+
async fn schedule_gate_opens_inside_window() {
2491+
// Same window, but advance the clock to 09:00 UTC so the gate opens and
2492+
// the cycle proceeds past the schedule gate (no Schedule pause).
2493+
let account = AccountId::new_v4();
2494+
let dir = tempfile::tempdir().unwrap();
2495+
let src = source_in(account, dir.path());
2496+
let exec = Arc::new(RecordingExecutor::default());
2497+
let cfg = OrchestratorConfig {
2498+
schedule: crate::types::ScheduleConfig {
2499+
enabled: true,
2500+
start_minute: 9 * 60,
2501+
end_minute: 17 * 60,
2502+
days: [true; 7],
2503+
utc_offset_minutes: 0,
2504+
},
2505+
..OrchestratorConfig::default()
2506+
};
2507+
let (orch, clock) = build(
2508+
account,
2509+
vec![src],
2510+
exec.clone(),
2511+
power_on_ac(),
2512+
Arc::new(FakeNet::online()),
2513+
cfg,
2514+
);
2515+
clock.advance(std::time::Duration::from_secs(9 * 3600)); // -> 09:00 UTC
2516+
2517+
orch.run_cycle(TickSource::Scheduled).await.unwrap();
2518+
2519+
assert_ne!(
2520+
orch.state().await,
2521+
OrchestratorState::Paused {
2522+
reason: PauseReason::Schedule
2523+
},
2524+
"inside the window the schedule gate must not pause"
2525+
);
2526+
}
2527+
24312528
#[tokio::test]
24322529
async fn ac_resumes_after_battery_pause() {
24332530
// Power gate: pause on battery, resume on AC (the two-cycle path).

crates/driven-core/src/types.rs

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,109 @@ pub enum PauseReason {
326326
/// DNS broken; SPEC s24 `net.dns_failed`). Kept distinct from [`Offline`]
327327
/// per CODEX_NOTES P2-9 (M4).
328328
DnsFailed,
329+
/// Outside the user's configured schedule window (V2 schedule windows,
330+
/// DESIGN s17). The orchestrator resumes automatically once the local
331+
/// clock re-enters the allowed window - no manual action required.
332+
Schedule,
333+
}
334+
335+
// -----------------------------------------------------------------------------
336+
// ScheduleConfig (V2 schedule windows - DESIGN s17)
337+
// -----------------------------------------------------------------------------
338+
339+
/// A time-of-day + day-of-week window during which sync is allowed (V2
340+
/// schedule windows, DESIGN s17 "only sync 23:00-06:00").
341+
///
342+
/// The window is expressed in the user's LOCAL wall-clock time. Like the
343+
/// pacer's "midnight Pacific" quota boundary (see [`crate::pacer`]),
344+
/// `driven-core` stays free of a timezone database: local time is derived
345+
/// from a fixed [`Self::utc_offset_minutes`] the app layer captures from the
346+
/// OS / browser. The bounded consequence is the same as the pacer's - across
347+
/// a DST transition the window shifts by up to an hour until the app
348+
/// re-reads the offset. This is deliberate and documented (DESIGN s17).
349+
///
350+
/// The predicate is a pure function of the injected [`Clock`](crate::time::Clock)
351+
/// reading, so the orchestrator gate is deterministic under `FakeClock`.
352+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353+
pub struct ScheduleConfig {
354+
/// When `false` the schedule never gates (sync runs at any time). This is
355+
/// the V1 behaviour and the [`Default`].
356+
pub enabled: bool,
357+
/// Minutes after local midnight the allowed window opens, `0..=1439`.
358+
pub start_minute: u16,
359+
/// Minutes after local midnight the allowed window closes, `0..=1439`.
360+
///
361+
/// - `end > start`: a same-day window `[start, end)`.
362+
/// - `end < start`: the window wraps past midnight (active `[start, 1440)`
363+
/// and `[0, end)`).
364+
/// - `end == start`: the whole day is allowed (only [`Self::days`] gates).
365+
pub end_minute: u16,
366+
/// Which local days the window is active on, indexed `0 = Sunday ..=
367+
/// 6 = Saturday` to match JavaScript's `Date.getDay()`. The window is
368+
/// evaluated against the CURRENT local day, so a window that wraps past
369+
/// midnight (e.g. 23:00-06:00) needs both the evening day and the
370+
/// following morning's day enabled for the whole window to be allowed.
371+
pub days: [bool; 7],
372+
/// Minutes to ADD to UTC to reach the user's local wall-clock time
373+
/// (e.g. `-480` for PST = UTC-8). The app layer sets this from the OS;
374+
/// the browser value is `-new Date().getTimezoneOffset()`.
375+
pub utc_offset_minutes: i16,
376+
}
377+
378+
impl Default for ScheduleConfig {
379+
/// Disabled: sync runs at any time (V1 behaviour). The window fields are
380+
/// inert while `enabled` is false.
381+
fn default() -> Self {
382+
Self {
383+
enabled: false,
384+
start_minute: 0,
385+
end_minute: 0,
386+
days: [true; 7],
387+
utc_offset_minutes: 0,
388+
}
389+
}
390+
}
391+
392+
impl ScheduleConfig {
393+
/// Milliseconds per minute / minutes per day, for the local-time maths.
394+
const MS_PER_MIN: i64 = 60_000;
395+
const MINS_PER_DAY: i64 = 1_440;
396+
397+
/// True if sync is allowed at the wall-clock instant `now_ms`.
398+
///
399+
/// A disabled schedule always allows. Otherwise the UTC instant is shifted
400+
/// into local wall time by [`Self::utc_offset_minutes`], reduced to a
401+
/// local day-of-week + minute-of-day, and tested against the window. Uses
402+
/// Euclidean division/remainder so a negative (pre-epoch) or
403+
/// backwards-jumped clock reading still yields an in-range day/minute
404+
/// rather than a panic (DESIGN s18.7 - the clock may move backwards).
405+
pub fn allows(&self, now_ms: UnixMs) -> bool {
406+
if !self.enabled {
407+
return true;
408+
}
409+
let local_ms = now_ms.saturating_add((self.utc_offset_minutes as i64) * Self::MS_PER_MIN);
410+
let total_min = local_ms.div_euclid(Self::MS_PER_MIN);
411+
let min_of_day = total_min.rem_euclid(Self::MINS_PER_DAY) as u16;
412+
// Days since the Unix epoch in local time. 1970-01-01 was a Thursday,
413+
// which is `getDay() == 4`, so offset the day count by 4 before the
414+
// mod-7 to land on the Sunday-indexed weekday.
415+
let day_index = total_min.div_euclid(Self::MINS_PER_DAY);
416+
let dow = (day_index + 4).rem_euclid(7) as usize;
417+
if !self.days[dow] {
418+
return false;
419+
}
420+
let (s, e) = (self.start_minute, self.end_minute);
421+
if s == e {
422+
// Whole day allowed; only the day-of-week gates.
423+
return true;
424+
}
425+
if s < e {
426+
min_of_day >= s && min_of_day < e
427+
} else {
428+
// Wraps past midnight.
429+
min_of_day >= s || min_of_day < e
430+
}
431+
}
329432
}
330433

331434
// -----------------------------------------------------------------------------
@@ -1209,4 +1312,113 @@ mod tests {
12091312
let rp: RelativePath = std::path::Path::new("a/b.txt").try_into().unwrap();
12101313
assert_eq!(rp.as_str(), "a/b.txt");
12111314
}
1315+
1316+
// --- ScheduleConfig (V2 schedule windows) -------------------------------
1317+
1318+
/// Monday 2024-01-01 00:00:00 UTC, in epoch ms. The dow formula resolves
1319+
/// this to `getDay() == 1` (Monday); used as the anchor for the cases
1320+
/// below (offsets in minutes/days are added on top).
1321+
const MON_2024_01_01_UTC_MS: UnixMs = 1_704_067_200_000;
1322+
const MIN_MS: UnixMs = 60_000;
1323+
const DAY_MS: UnixMs = 1_440 * MIN_MS;
1324+
1325+
fn all_days() -> [bool; 7] {
1326+
[true; 7]
1327+
}
1328+
1329+
#[test]
1330+
fn schedule_disabled_always_allows() {
1331+
let s = ScheduleConfig::default();
1332+
assert!(!s.enabled);
1333+
assert!(s.allows(MON_2024_01_01_UTC_MS));
1334+
assert!(s.allows(0));
1335+
assert!(s.allows(-1)); // pre-epoch must not panic
1336+
}
1337+
1338+
#[test]
1339+
fn schedule_same_day_window_half_open() {
1340+
// 09:00-17:00 every day.
1341+
let s = ScheduleConfig {
1342+
enabled: true,
1343+
start_minute: 9 * 60,
1344+
end_minute: 17 * 60,
1345+
days: all_days(),
1346+
utc_offset_minutes: 0,
1347+
};
1348+
let at = |min: i64| s.allows(MON_2024_01_01_UTC_MS + min * MIN_MS);
1349+
assert!(!at(0)); // 00:00 - before
1350+
assert!(!at(8 * 60 + 59)); // 08:59 - before
1351+
assert!(at(9 * 60)); // 09:00 - open (inclusive)
1352+
assert!(at(16 * 60 + 59)); // 16:59 - inside
1353+
assert!(!at(17 * 60)); // 17:00 - close (exclusive)
1354+
assert!(!at(23 * 60)); // 23:00 - after
1355+
}
1356+
1357+
#[test]
1358+
fn schedule_wrap_past_midnight() {
1359+
// 23:00-06:00 every day.
1360+
let s = ScheduleConfig {
1361+
enabled: true,
1362+
start_minute: 23 * 60,
1363+
end_minute: 6 * 60,
1364+
days: all_days(),
1365+
utc_offset_minutes: 0,
1366+
};
1367+
let at = |min: i64| s.allows(MON_2024_01_01_UTC_MS + min * MIN_MS);
1368+
assert!(at(23 * 60)); // 23:00 - open
1369+
assert!(at(23 * 60 + 30)); // 23:30 - evening tail
1370+
assert!(at(0)); // 00:00 - past midnight
1371+
assert!(at(5 * 60 + 59)); // 05:59 - morning
1372+
assert!(!at(6 * 60)); // 06:00 - close (exclusive)
1373+
assert!(!at(12 * 60)); // noon - outside
1374+
}
1375+
1376+
#[test]
1377+
fn schedule_equal_bounds_is_whole_day() {
1378+
// start == end => only the day-of-week gates.
1379+
let s = ScheduleConfig {
1380+
enabled: true,
1381+
start_minute: 0,
1382+
end_minute: 0,
1383+
days: all_days(),
1384+
utc_offset_minutes: 0,
1385+
};
1386+
for h in [0, 6, 12, 18, 23] {
1387+
assert!(s.allows(MON_2024_01_01_UTC_MS + h * 60 * MIN_MS));
1388+
}
1389+
}
1390+
1391+
#[test]
1392+
fn schedule_day_of_week_gates() {
1393+
// Whole-day window, but only Monday (index 1) enabled.
1394+
let mut days = [false; 7];
1395+
days[1] = true; // Monday
1396+
let s = ScheduleConfig {
1397+
enabled: true,
1398+
start_minute: 0,
1399+
end_minute: 0,
1400+
days,
1401+
utc_offset_minutes: 0,
1402+
};
1403+
assert!(s.allows(MON_2024_01_01_UTC_MS)); // Monday
1404+
assert!(!s.allows(MON_2024_01_01_UTC_MS + DAY_MS)); // Tuesday
1405+
assert!(!s.allows(MON_2024_01_01_UTC_MS - DAY_MS)); // Sunday
1406+
assert!(s.allows(MON_2024_01_01_UTC_MS + 7 * DAY_MS)); // next Monday
1407+
}
1408+
1409+
#[test]
1410+
fn schedule_utc_offset_shifts_local_time() {
1411+
// 00:00-01:00 LOCAL, every day, at UTC+1. 00:00 UTC == 01:00 local,
1412+
// which is outside [00:00, 01:00); one hour earlier (23:00 UTC) ==
1413+
// 00:00 local, which is inside.
1414+
let s = ScheduleConfig {
1415+
enabled: true,
1416+
start_minute: 0,
1417+
end_minute: 60,
1418+
days: all_days(),
1419+
utc_offset_minutes: 60,
1420+
};
1421+
assert!(!s.allows(MON_2024_01_01_UTC_MS)); // 01:00 local
1422+
assert!(s.allows(MON_2024_01_01_UTC_MS - 60 * MIN_MS)); // 00:00 local
1423+
}
12121424
}

src-tauri/locales/en-US.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ tray:
2020
paused_manual: "Driven - paused"
2121
paused_battery: "Driven - paused on battery power"
2222
paused_metered: "Driven - paused on metered network"
23+
paused_schedule: "Driven - paused outside the scheduled window"
2324
offline: "Connected, no Internet"
2425
no_internet: "Connected, no Internet"
2526
captive_portal: "Captive portal - click to sign in"

0 commit comments

Comments
 (0)