@@ -1968,19 +1968,62 @@ fn live_mode_has_mounted_folder(snapshot: &DesktopSnapshot) -> bool {
19681968}
19691969
19701970fn live_mode_enabled_mount(state_root: &Path) -> Result<Option<MountConfig>, String> {
1971- let store = SqliteStateStore::open(state_root.to_path_buf())
1971+ let mut store = SqliteStateStore::open(state_root.to_path_buf())
19721972 .map_err(|error| format!("Live Mode could not open Locality state: {error}"))?;
19731973 let mounts = store
19741974 .load_mounts()
19751975 .map_err(|error| format!("Live Mode could not inspect mounted folders: {error}"))?;
19761976 let Some(mount) = choose_mount(&mounts) else {
19771977 return Ok(None);
19781978 };
1979- let enabled = store
1979+ let Some(record) = store
19801980 .get_mount_live_mode(&mount.mount_id)
19811981 .map_err(|error| format!("Live Mode could not inspect its state: {error}"))?
1982- .is_some_and(|record| record.enabled);
1983- Ok(enabled.then_some(mount))
1982+ else {
1983+ return Ok(None);
1984+ };
1985+ if record.enabled {
1986+ return Ok(Some(mount));
1987+ }
1988+ if live_mode_resume_stale_disabled_pause_if_safe(&mut store, state_root, &mount, &record)? {
1989+ return Ok(Some(mount));
1990+ }
1991+ Ok(None)
1992+ }
1993+
1994+ fn live_mode_resume_stale_disabled_pause_if_safe(
1995+ store: &mut SqliteStateStore,
1996+ state_root: &Path,
1997+ mount: &MountConfig,
1998+ record: &MountLiveModeRecord,
1999+ ) -> Result<bool, String> {
2000+ if !live_mode_disabled_pause_can_resume(record) {
2001+ return Ok(false);
2002+ }
2003+ let pending_changes = pending_changes_for_mount(store, state_root, &mount.mount_id)?;
2004+ if pending_changes.is_empty() || pending_changes.iter().any(|change| change.state != "safe") {
2005+ return Ok(false);
2006+ }
2007+
2008+ let now = live_mode_timestamp();
2009+ let resumed = record.clone().active(
2010+ Some("Live Mode resumed after remote drift cleared.".to_string()),
2011+ now.clone(),
2012+ now,
2013+ );
2014+ store.save_mount_live_mode(resumed).map_err(|error| {
2015+ format!("Live Mode could not resume after remote drift cleared: {error}")
2016+ })?;
2017+ Ok(true)
2018+ }
2019+
2020+ fn live_mode_disabled_pause_can_resume(record: &MountLiveModeRecord) -> bool {
2021+ !record.enabled
2022+ && record.state == MountLiveModeState::Error
2023+ && record.last_reason.as_deref().is_some_and(|reason| {
2024+ live_mode_failure_should_pause(reason)
2025+ && reason.contains("remote changed while local edits are pending")
2026+ })
19842027}
19852028
19862029#[derive(Debug, Default)]
@@ -10295,12 +10338,13 @@ mod tests {
1029510338 hydration_after_editor_write, inspect_install_state, install_terminal_cli_link_at,
1029610339 install_terminal_cli_link_in_path_dirs, is_notion_access_lost_message,
1029710340 is_unsupported_schema_version_message, live_mode_claim_remote_fast_forward_key,
10298- live_mode_local_reconcile_targets_for_mount_at, live_mode_merge_remote_drift_markdown,
10299- live_mode_release_remote_fast_forward_key, live_mode_remote_check_page_budget_for_rate,
10300- live_mode_remote_pull_candidates, live_mode_remote_pull_scan_is_due_for_key,
10301- live_mode_runner_should_tick, live_mode_should_reconcile_local_target_for_key,
10302- live_mode_target, live_mode_tick_from_snapshot, live_mode_wake_generation,
10303- load_desktop_activity, macos_app_bundle_for_exe, macos_file_provider_child_item_count,
10341+ live_mode_enabled_mount, live_mode_local_reconcile_targets_for_mount_at,
10342+ live_mode_merge_remote_drift_markdown, live_mode_release_remote_fast_forward_key,
10343+ live_mode_remote_check_page_budget_for_rate, live_mode_remote_pull_candidates,
10344+ live_mode_remote_pull_scan_is_due_for_key, live_mode_runner_should_tick,
10345+ live_mode_should_reconcile_local_target_for_key, live_mode_target,
10346+ live_mode_tick_from_snapshot, live_mode_wake_generation, load_desktop_activity,
10347+ macos_app_bundle_for_exe, macos_file_provider_child_item_count,
1030410348 macos_file_provider_mount_root_health_error,
1030510349 macos_file_provider_mount_root_recovery_reason, mark_mount_live_mode_syncing,
1030610350 mount_has_pending_local_changes, mount_has_unfinished_journals, notion_id_from_url,
@@ -11371,6 +11415,112 @@ mod tests {
1137111415 assert_eq!(summary.reason, None);
1137211416 }
1137311417
11418+ #[test]
11419+ fn live_mode_disabled_remote_drift_pause_can_resume() {
11420+ let record = MountLiveModeRecord::new(MountId::new("notion-main"), true, "1").error(
11421+ "Live Mode paused for `Roadmap`: remote changed while local edits are pending.",
11422+ "2",
11423+ "2",
11424+ );
11425+
11426+ assert!(super::live_mode_disabled_pause_can_resume(&record));
11427+ }
11428+
11429+ #[test]
11430+ fn live_mode_disabled_non_remote_drift_pause_stays_paused() {
11431+ let record = MountLiveModeRecord::new(MountId::new("notion-main"), true, "1").error(
11432+ "Live Mode paused for `Roadmap`: conflict.",
11433+ "2",
11434+ "2",
11435+ );
11436+
11437+ assert!(!super::live_mode_disabled_pause_can_resume(&record));
11438+ }
11439+
11440+ #[test]
11441+ fn live_mode_enabled_mount_resumes_stale_remote_drift_pause_for_safe_pending_change() {
11442+ let temp = TestTempDir::new("live-mode-resume-stale-remote-drift");
11443+ let state_root = temp.path();
11444+ let mount_root = state_root.join("notion");
11445+ let mut store = SqliteStateStore::open(state_root.to_path_buf()).expect("open store");
11446+ let mount_id = MountId::new("notion-main");
11447+ let remote_id = RemoteId::new("page-1");
11448+ let relative_path = Path::new("Roadmap/page.md");
11449+ let body = "# Roadmap\n\nOriginal body.\n";
11450+ let mount = MountConfig::new(mount_id.clone(), "notion", &mount_root)
11451+ .projection(ProjectionMode::MacosFileProvider);
11452+ store.save_mount(mount.clone()).expect("save mount");
11453+ store
11454+ .save_mount_live_mode(MountLiveModeRecord::new(mount_id.clone(), true, "1").error(
11455+ "Live Mode paused for `Roadmap`: remote changed while local edits are pending.",
11456+ "2",
11457+ "2",
11458+ ))
11459+ .expect("save paused live mode");
11460+ store
11461+ .save_entity(
11462+ EntityRecord::new(
11463+ mount_id.clone(),
11464+ remote_id.clone(),
11465+ EntityKind::Page,
11466+ "Roadmap",
11467+ relative_path,
11468+ )
11469+ .with_hydration(HydrationState::Dirty)
11470+ .with_remote_edited_at("remote-v1"),
11471+ )
11472+ .expect("save entity");
11473+ store
11474+ .save_shadow(
11475+ &mount_id,
11476+ ShadowDocument::from_synced_body(
11477+ remote_id.clone(),
11478+ body,
11479+ 7,
11480+ [RemoteId::new("heading-1"), RemoteId::new("paragraph-1")],
11481+ )
11482+ .expect("shadow"),
11483+ )
11484+ .expect("save shadow");
11485+ store
11486+ .save_remote_observation(
11487+ RemoteObservationRecord::new(
11488+ mount_id.clone(),
11489+ remote_id.clone(),
11490+ EntityKind::Page,
11491+ "Roadmap",
11492+ relative_path,
11493+ "unix_ms:10",
11494+ )
11495+ .with_remote_version(RemoteVersion::new("remote-v1")),
11496+ )
11497+ .expect("save observation");
11498+ store
11499+ .save_freshness_state(
11500+ FreshnessStateRecord::new(mount_id.clone(), remote_id.clone(), FreshnessTier::Hot)
11501+ .checked_at("unix_ms:10"),
11502+ )
11503+ .expect("save freshness");
11504+
11505+ let content_path =
11506+ localityd::virtual_fs::virtual_fs_content_path(state_root, &mount_id, relative_path)
11507+ .expect("content path");
11508+ fs::create_dir_all(content_path.parent().expect("content parent"))
11509+ .expect("create content parent");
11510+ fs::write(&content_path, "# Roadmap\n\nLocal edit.\n").expect("write dirty content");
11511+
11512+ let selected = live_mode_enabled_mount(state_root)
11513+ .expect("enabled mount")
11514+ .expect("resumed mount");
11515+ assert_eq!(selected.mount_id, mount_id);
11516+ let record = store
11517+ .get_mount_live_mode(&mount_id)
11518+ .expect("load live mode")
11519+ .expect("live mode record");
11520+ assert!(record.enabled);
11521+ assert_eq!(record.state, MountLiveModeState::Active);
11522+ }
11523+
1137411524 #[test]
1137511525 fn live_mode_transient_failures_remain_enabled_for_retry() {
1137611526 let temp = TestTempDir::new("live-mode-transient-failure");
0 commit comments