diff --git a/crates/engine/src/local_import.rs b/crates/engine/src/local_import.rs index 90fb8020d..0a417afa7 100644 --- a/crates/engine/src/local_import.rs +++ b/crates/engine/src/local_import.rs @@ -63,6 +63,10 @@ struct MarkerEntry { imported_at_ms: i64, imported_chats: usize, imported_spaces: usize, + /// The last run for this account ended with errors — the UI owes the + /// user a retry entry point even across an app restart. + #[serde(default)] + pending_retry: bool, } /// What the wizard needs to offer (or silently skip) the import step. @@ -75,6 +79,10 @@ pub struct LocalImportStatus { pub available_spaces: usize, /// A completed import for this (org, user) is already on record. pub imported_before: bool, + /// The last recorded run for this (org, user) ended with errors and has + /// not been retried to completion — restart-durable, so a rebooted app + /// can restore the retry entry point. + pub pending_retry: bool, } /// Per-item progress for the wizard's progress step. @@ -190,12 +198,15 @@ impl LocalImporter { } } - fn imported_before(&self) -> bool { + /// `(imported_before, pending_retry)` for this account, one locked read. + fn marker_state(&self) -> (bool, bool) { let _guard = marker_lock(); - self.load_marker_locked() + let marker = self.load_marker_locked(); + let entry = marker .imports .iter() - .any(|e| e.org_id == self.inner.org_id && e.user_id == self.inner.user_id) + .find(|e| e.org_id == self.inner.org_id && e.user_id == self.inner.user_id); + (entry.is_some(), entry.is_some_and(|e| e.pending_retry)) } /// Record a completed import and re-arm the read-only uploads root for @@ -207,7 +218,12 @@ impl LocalImporter { /// marker — so a crash or short write can never truncate the file and /// erase other accounts' grants. Failures propagate to the caller and end /// up in the import summary. - fn record_import(&self, chats: usize, spaces: usize) -> Result<(), EngineError> { + fn record_import( + &self, + chats: usize, + spaces: usize, + pending_retry: bool, + ) -> Result<(), EngineError> { let _guard = marker_lock(); let mut marker = self.load_marker_locked(); marker @@ -219,8 +235,15 @@ impl LocalImporter { imported_at_ms: crate::now_ms(), imported_chats: chats, imported_spaces: spaces, + pending_retry, }); - let bytes = serde_json::to_vec_pretty(&marker) + self.publish_marker_locked(&marker) + } + + /// Atomic marker publish (caller holds [`marker_lock`]): write + fsync a + /// sibling temp file, rename over the marker. + fn publish_marker_locked(&self, marker: &Marker) -> Result<(), EngineError> { + let bytes = serde_json::to_vec_pretty(marker) .map_err(|err| EngineError::Other(format!("marker serialize: {err}")))?; let path = self.marker_path(); let tmp = path.with_extension("json.tmp"); @@ -239,6 +262,40 @@ impl LocalImporter { }) } + /// Flip only this account's `pending_retry` flag. Setting creates a + /// zero-count entry when none exists (the attempt is the fact worth + /// recording); clearing an absent entry is a no-op. Used to ARM intent + /// before the fallible import body runs — any exit that skips the final + /// [`Self::record_import`] (a `?` error, a marker-write failure at the + /// end) leaves the armed flag on disk, so a restarted app still restores + /// the retry entry point. + fn set_pending_retry(&self, pending: bool) -> Result<(), EngineError> { + let _guard = marker_lock(); + let mut marker = self.load_marker_locked(); + let entry = marker + .imports + .iter_mut() + .find(|e| e.org_id == self.inner.org_id && e.user_id == self.inner.user_id); + match (entry, pending) { + (Some(entry), _) => { + if entry.pending_retry == pending { + return Ok(()); // no-op: don't churn the file + } + entry.pending_retry = pending; + } + (None, true) => marker.imports.push(MarkerEntry { + org_id: self.inner.org_id.clone(), + user_id: self.inner.user_id.clone(), + imported_at_ms: crate::now_ms(), + imported_chats: 0, + imported_spaces: 0, + pending_retry: true, + }), + (None, false) => return Ok(()), + } + self.publish_marker_locked(&marker) + } + /// Open the local profile's stores read-only-ish. `None` when the device /// never ran a local profile (nothing to import). fn open_source(&self) -> Result, EngineError> { @@ -254,15 +311,32 @@ impl LocalImporter { Ok(Some((store, registry))) } - /// What's importable right now (target-dedup applied). + /// What's importable right now (target-dedup applied). Marker state + /// (`imported_before`, `pending_retry`) is reported unconditionally: an + /// unreadable local store must not hide a recorded pending retry from the + /// boot probe, so availability scanning is best-effort (zeros on error). pub fn status(&self) -> Result { - let imported_before = self.imported_before(); + let (imported_before, pending_retry) = self.marker_state(); + let (available_chats, available_spaces) = match self.scan_available() { + Ok(counts) => counts, + Err(err) => { + tracing::warn!(error = %err, "local-import availability scan failed"); + (0, 0) + } + }; + Ok(LocalImportStatus { + available_chats, + available_spaces, + imported_before, + pending_retry, + }) + } + + /// Source-side availability, target-deduplicated. Fallible: the local + /// store may be unreadable, which [`Self::status`] treats as zeros. + fn scan_available(&self) -> Result<(usize, usize), EngineError> { let Some((_, registry)) = self.open_source()? else { - return Ok(LocalImportStatus { - available_chats: 0, - available_spaces: 0, - imported_before, - }); + return Ok((0, 0)); }; let mut available_chats = 0; for chat in registry.read_chats()? { @@ -276,17 +350,31 @@ impl LocalImporter { available_spaces += 1; } } - Ok(LocalImportStatus { - available_chats, - available_spaces, - imported_before, - }) + Ok((available_chats, available_spaces)) } /// Run the import, emitting [`ImportEvent`]s (the last is always /// `Summary`). Blocking (sqlite + fs) — callers run it off the async path. + /// + /// Pending-retry discipline: intent is ARMED on disk before any fallible + /// work and cleared only by a clean terminal outcome — so a `?` error, a + /// per-item failure, or a failed final marker write all leave the armed + /// flag for the next boot to restore. (If the arming write itself fails, + /// nothing can be persisted; the error still reaches the summary, and the + /// manual "Import local work" menu entry remains the recovery route.) pub fn run(&self, mut emit: impl FnMut(ImportEvent)) -> Result<(), EngineError> { + let mut errors: Vec = Vec::new(); + if let Err(err) = self.set_pending_retry(true) { + errors.push(format!("import marker: {err}")); + } + let Some((source_store, registry)) = self.open_source()? else { + // Clean terminal outcome: nothing to import (the local profile or + // its registry is gone). A stale armed flag from an earlier + // failure must not haunt every future boot. + if let Err(err) = self.set_pending_retry(false) { + errors.push(format!("import marker: {err}")); + } emit(ImportEvent::Start { chats: 0, spaces: 0, @@ -298,13 +386,11 @@ impl LocalImporter { skipped_spaces: 0, journals_copied: 0, ledger_rows_merged: 0, - errors: Vec::new(), + errors, }); return Ok(()); }; - let mut errors: Vec = Vec::new(); - // Spaces first: chats reference `space_id`, and viewers resolve the // reference as soon as the chat row lands. let spaces = registry.read_spaces()?; @@ -375,7 +461,12 @@ impl LocalImporter { .uploads .add_read_only_root(&self.source_uploads()); } - if let Err(err) = self.record_import(imported_chats, imported_spaces) { + // Terminal outcome: a clean run clears the armed flag via the full + // record; an errored run re-records it with the partial counts. If + // THIS write fails, the flag armed at the top of `run` is still on + // disk — the restart keeps its retry entry point either way. + let pending_retry = !errors.is_empty(); + if let Err(err) = self.record_import(imported_chats, imported_spaces, pending_retry) { errors.push(format!("import marker: {err}")); } diff --git a/crates/engine/tests/local_import.rs b/crates/engine/tests/local_import.rs index 1457d6d5b..b529b11a7 100644 --- a/crates/engine/tests/local_import.rs +++ b/crates/engine/tests/local_import.rs @@ -302,6 +302,18 @@ async fn per_item_failures_surface_in_the_summary_and_leave_the_row_retryable() .is_some() ); + // The failed run records restart-durable retry intent. + let status = synced + .local_import + .as_ref() + .expect("importer") + .status() + .expect("status"); + assert!( + status.pending_retry, + "a failed run must persist pending-retry for the next boot" + ); + // Retry after clearing the obstruction: only the failed chat imports. std::fs::remove_file(&target_journals).expect("clear obstruction"); let events = run_import(&synced); @@ -309,6 +321,15 @@ async fn per_item_failures_surface_in_the_summary_and_leave_the_row_retryable() assert!(errors.is_empty(), "retry is clean: {errors:?}"); assert_eq!((imported, skipped), (1, 1)); + // A clean retry clears the persisted intent. + let status = synced + .local_import + .as_ref() + .expect("importer") + .status() + .expect("status"); + assert!(!status.pending_retry, "clean retry clears pending-retry"); + synced.shutdown().await; } @@ -358,6 +379,146 @@ async fn marker_persistence_failure_is_an_import_error() { "marker failure must be reported: {errors:?}" ); synced.shutdown().await; + drop(synced); + + // Restarted status: with the marker unwritable nothing could persist, so + // pending is honestly false — but the availability scan still reports the + // un-imported local rows (the row import failed against nothing here, so + // dedupe finds them present; the bare fact asserted is that status + // answers and the manual "Import local work" route has its data). + let restarted = assemble(EngineProfile::synced(dir.path(), "org1", "user1")); + let status = restarted + .local_import + .as_ref() + .expect("importer") + .status() + .expect("status still answers with an obstructed marker"); + assert!(!status.pending_retry, "nothing could be persisted"); + restarted.shutdown().await; +} + +/// Review point 1: a `?` exit from `run` (before the item loop) must still +/// arm restart-durable retry intent, and the armed flag must survive a +/// process restart. +#[tokio::test] +async fn top_level_import_error_arms_pending_retry_across_restart() { + let dir = tempfile::tempdir().expect("tempdir"); + let (..) = seed_local(dir.path()).await; + + // Corrupt the SOURCE store so `open_source` fails outright. + let source_db = dir + .path() + .join("profiles") + .join("local") + .join("docs.sqlite3"); + std::fs::write(&source_db, b"not a sqlite database").expect("corrupt source"); + for sidecar in ["docs.sqlite3-wal", "docs.sqlite3-shm"] { + let _ = std::fs::remove_file(dir.path().join("profiles").join("local").join(sidecar)); + } + + let synced = assemble(EngineProfile::synced(dir.path(), "org1", "user1")); + let importer = synced.local_import.clone().expect("importer"); + let result = importer.run(|_| {}); + assert!(result.is_err(), "corrupt source must error the run"); + assert!( + importer.status().expect("status").pending_retry, + "the top-level error must arm pending retry" + ); + synced.shutdown().await; + drop(synced); + + // Restart: a fresh runtime still reports it. + let restarted = assemble(EngineProfile::synced(dir.path(), "org1", "user1")); + let status = restarted + .local_import + .as_ref() + .expect("importer") + .status() + .expect("status"); + assert!(status.pending_retry, "armed intent survives the restart"); + assert_eq!( + (status.available_chats, status.available_spaces), + (0, 0), + "availability is best-effort zeros when the source is unreadable" + ); + restarted.shutdown().await; +} + +/// Review point 2: a retry that finds no local profile at all is a CLEAN +/// terminal outcome — it must clear a previously armed flag instead of +/// haunting every future boot. +#[tokio::test] +async fn clean_no_source_retry_clears_a_stale_pending_flag() { + let dir = tempfile::tempdir().expect("tempdir"); + let (_device, chat_doc, _) = seed_local(dir.path()).await; + + let synced = assemble(EngineProfile::synced(dir.path(), "org1", "user1")); + let importer = synced.local_import.clone().expect("importer"); + + // Arm the flag via a real failure (journal obstruction). + let target_journals = dir + .path() + .join("orgs") + .join("org1") + .join("user1") + .join("journals"); + std::fs::remove_dir_all(&target_journals).expect("clear journals dir"); + std::fs::write(&target_journals, b"obstruction").expect("plant obstruction"); + let events = run_import(&synced); + assert!(!raw_summary(&events).2.is_empty(), "failure arms the flag"); + assert!(importer.status().expect("status").pending_retry); + let _ = chat_doc; + + // The user then deletes the local profile entirely. + std::fs::remove_dir_all(dir.path().join("profiles")).expect("remove local profile"); + + let events = run_import(&synced); + let (_, _, errors) = raw_summary(&events); + assert!(errors.is_empty(), "no-source retry is clean: {errors:?}"); + assert!( + !importer.status().expect("status").pending_retry, + "the clean no-op must clear the armed flag" + ); + synced.shutdown().await; +} + +/// Review point 3 (engine half): marker state must not be hostage to the +/// availability scan — an unreadable source store cannot hide a recorded +/// pending retry from the boot probe. +#[tokio::test] +async fn status_reports_pending_retry_despite_unreadable_source() { + let dir = tempfile::tempdir().expect("tempdir"); + let (..) = seed_local(dir.path()).await; + + let synced = assemble(EngineProfile::synced(dir.path(), "org1", "user1")); + let importer = synced.local_import.clone().expect("importer"); + + // Arm via a journal-obstruction failure… + let target_journals = dir + .path() + .join("orgs") + .join("org1") + .join("user1") + .join("journals"); + std::fs::remove_dir_all(&target_journals).expect("clear journals dir"); + std::fs::write(&target_journals, b"obstruction").expect("plant obstruction"); + run_import(&synced); + assert!(importer.status().expect("status").pending_retry); + + // …then make the source store unreadable. + std::fs::write( + dir.path() + .join("profiles") + .join("local") + .join("docs.sqlite3"), + b"garbage", + ) + .expect("corrupt source"); + let status = importer + .status() + .expect("status must not fail on scan errors"); + assert!(status.pending_retry, "marker state independent of the scan"); + synced.shutdown().await; } #[tokio::test] diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 32fd685f5..85a4c045d 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -666,6 +666,44 @@ fn local_work_phrase(chats: usize, spaces: usize) -> Option { } } +const IMPORT_PROBE_MAX_ATTEMPTS: u8 = 4; + +/// What a LocalImportStatus probe result means for the shell. Pure so the +/// re-arm rules are testable: an error or a mistimed pending result must +/// DEFER (probe again later), never permanently swallow the recovery path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ImportProbeOutcome { + /// Recorded pending retry + idle flow: restore the postponed failure. + Restore, + /// Definitive answer, nothing to restore — stop probing this runtime. + Settle, + /// No usable answer (RPC failed) or the answer arrived while the flow + /// was busy — keep the probe armed for a later state change. + Defer, +} + +fn import_probe_outcome(status: Option<&serde_json::Value>, flow: SyncFlow) -> ImportProbeOutcome { + let Some(status) = status else { + return ImportProbeOutcome::Defer; + }; + let pending = status + .get("pendingRetry") + .and_then(|b| b.as_bool()) + .unwrap_or(false); + match (pending, flow) { + (false, _) => ImportProbeOutcome::Settle, + (true, SyncFlow::Idle) => ImportProbeOutcome::Restore, + (true, _) => ImportProbeOutcome::Defer, + } +} + +/// The manual "Import local work" account-menu row: the always-available +/// recovery route on a synced runtime with importable local rows — it works +/// even when the pending marker itself could not be written. +fn show_manual_import_row(scope: Option, flow: SyncFlow, available: usize) -> bool { + scope == Some(WorkspaceScope::Synced) && flow == SyncFlow::Idle && available > 0 +} + fn account_menu_action(scope: Option, flow: SyncFlow) -> Option { match scope { Some(WorkspaceScope::Local) => match flow { @@ -849,6 +887,21 @@ pub struct Shell { runtime_change_error: Option, /// The one-time local→synced import stream (switch wizard progress step). import_task: Option>, + /// One-shot LocalImportStatus probe per attached synced runtime — restores + /// the pending-retry entry point after an app restart. + import_status_probe: Option>, + /// Guard so the probe settles once per Ready runtime, re-armed on + /// replacement, on RPC failure (bounded retries), and when a pending + /// result had to be deferred because the flow was busy. + import_status_checked: bool, + /// Failed probe attempts for the current runtime (bounded so an engine + /// that keeps erroring cannot hot-loop the probe). + import_status_attempts: u8, + /// Importable local rows (chats + spaces) reported by the last successful + /// probe — drives the manual "Import local work" account-menu entry, the + /// recovery route that works even when the marker itself cannot be + /// written. + local_import_available: usize, /// Title of the chat the import stream is copying right now. import_current: Option, /// Kept for the failed-gate "Retry" action. @@ -1062,6 +1115,10 @@ impl Shell { runtime_change_error: None, import_task: None, import_current: None, + import_status_probe: None, + import_status_checked: false, + import_status_attempts: 0, + local_import_available: 0, boot, data_dir, settings, @@ -1115,6 +1172,10 @@ impl Shell { // The in-place local→synced switch: once the replacement runtime is // attached and Ready, kick the import (or finish) from here. self.drive_sync_switch(cx); + // Restart durability: a synced boot with a recorded pending-retry + // restores the ImportFailed entry point the pre-restart session + // promised with "Later". + self.maybe_restore_pending_import(cx); let signed_out_synced = { let state = state.read(cx); state.workspace_scope == Some(WorkspaceScope::Synced) @@ -2231,6 +2292,92 @@ impl Shell { cx.notify(); } + /// One-shot per attached synced runtime: ask the engine whether a prior + /// import run left a recorded pending retry (`LocalImportStatus.pendingRetry`, + /// restart-durable via the marker) and restore the postponed + /// `ImportFailed` state so the account menu regains its "finish sync + /// setup" entry. Without this, "Later" + an app restart would strand the + /// remaining local rows with no discoverable retry path. + fn maybe_restore_pending_import(&mut self, cx: &mut Context) { + let (ready_synced, engine) = { + let state = self.state.read(cx); + ( + matches!(state.connection, ConnectionStatus::Ready) + && state.workspace_scope == Some(WorkspaceScope::Synced), + state.engine().cloned(), + ) + }; + if !ready_synced { + // Re-arm for the next runtime (replacement window or reconnect). + self.import_status_checked = false; + self.import_status_attempts = 0; + self.local_import_available = 0; + self.import_status_probe = None; + return; + } + if self.import_status_checked + || self.import_status_probe.is_some() + || self.import_status_attempts >= IMPORT_PROBE_MAX_ATTEMPTS + || self.sync_flow != SyncFlow::Idle + || self.import_task.is_some() + || self.runtime_change_task.is_some() + { + return; + } + let Some(engine) = engine else { return }; + let call = Tokio::spawn(cx, async move { + engine + .client() + .call(methods::LOCAL_IMPORT_STATUS, serde_json::json!({})) + .await + }); + self.import_status_probe = Some(cx.spawn(async move |this, cx| { + let status = match call.await { + Ok(result) => result.map_err(|error| error.to_string()), + Err(join) => Err(join.to_string()), + }; + this.update(cx, |shell, cx| { + shell.import_status_probe = None; + match import_probe_outcome(status.as_ref().ok(), shell.sync_flow) { + ImportProbeOutcome::Restore => { + shell.import_status_checked = true; + shell.apply_probe_available(status.as_ref().ok()); + shell.sync_flow = SyncFlow::ImportFailed { notice_open: false }; + shell.runtime_change_error = + Some("A previous local-work import didn't finish.".into()); + cx.notify(); + } + ImportProbeOutcome::Settle => { + shell.import_status_checked = true; + shell.apply_probe_available(status.as_ref().ok()); + cx.notify(); + } + // A failed call, or a pending result that arrived while + // the flow was busy: leave the guard unset so a later + // state change re-probes instead of hiding the recovery + // entry for the rest of the runtime. + ImportProbeOutcome::Defer => { + if status.is_err() { + shell.import_status_attempts = + shell.import_status_attempts.saturating_add(1); + } + } + } + }) + .ok(); + })); + } + + fn apply_probe_available(&mut self, status: Option<&serde_json::Value>) { + let count = |key: &str| { + status + .and_then(|s| s.get(key)) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize + }; + self.local_import_available = count("availableChats") + count("availableSpaces"); + } + /// Advance the in-place switch when the replacement runtime lands: Ready + /// Synced starts the import stream (or finishes immediately when the user /// chose a fresh start); a runtime that comes back non-synced fell out of @@ -3786,6 +3933,34 @@ impl Shell { }; menu.child(row).child(popover::menu_separator()) }) + // Manual recovery route: importable local rows on a synced + // runtime get a standing entry regardless of marker state — + // this is the path that still works when the pending marker + // itself could not be written. + .when( + show_manual_import_row( + self.state.read(cx).workspace_scope, + self.sync_flow, + self.local_import_available, + ), + |menu| { + menu.child( + popover::menu_row(theme, false, "user-menu-import-local") + .id("user-menu-import-local") + .on_click(cx.listener(|this, _, _, cx| { + this.close_user_menu(cx); + this.spawn_local_import(cx); + })) + .child( + icon(icons::GLOBAL) + .size(px(16.0)) + .text_color(theme.text_muted), + ) + .child(SharedString::from("Import local work")), + ) + .child(popover::menu_separator()) + }, + ) .child( popover::menu_row(theme, false, "user-menu-settings") .id("user-menu-settings") @@ -6462,6 +6637,155 @@ mod tests { release.await.unwrap(); } + /// The reviewer's restart scenario, end to end on real runtimes: a + /// partial import + "Later", then a full app restart (new bootstrap) must + /// still expose the retry — `LocalImportStatus.pendingRetry` survives on + /// disk, and the restore mapping (pending → postponed `ImportFailed` → + /// reopen menu action) is asserted against the restarted runtime's answer. + #[tokio::test] + async fn pending_import_retry_survives_an_app_restart() { + use zeron_engine::{EngineCore, EngineProfile, default_registry}; + + let dir = tempfile::tempdir().unwrap(); + + // A local-first stretch: one journal-less chat and one with a journal. + let local = EngineCore::assemble_with_profile( + EngineProfile::local(dir.path()).unwrap(), + std::sync::Arc::new(default_registry()), + zeron_proto::HarnessId::Mock, + None, + ) + .expect("assemble local profile"); + let device = local.device_id.clone(); + local + .workspace + .create_chat( + "chat-journaled", + None, + Some(&device), + None, + Some("/tmp".into()), + ) + .unwrap(); + local + .workspace + .create_chat("chat-bare", None, Some(&device), None, Some("/tmp".into())) + .unwrap(); + let journals = dir.path().join("profiles").join("local").join("journals"); + std::fs::create_dir_all(&journals).unwrap(); + let (journal, _) = zeron_engine::run_journal::journal_paths(&journals, "chat-journaled"); + std::fs::write(&journal, "{\"seq\":1,\"event\":{}}\n").unwrap(); + local.shutdown().await; + drop(local); + + // Signed-in synced boot (saved session), same shape as the sign-out test. + std::fs::write( + dir.path().join("session.json"), + r#"{"refreshToken":"still-valid","user":{"id":"user_1","email":"u@example.com"},"orgId":"org_1"}"#, + ) + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let boot = EngineBootConfig { + data_dir: dir.path().to_path_buf(), + ipc_port: port, + edge_url: "http://127.0.0.1:1".into(), + edge_token: None, + org_id: None, + workos_client_id: Some("client_test".into()), + default_harness: zeron_proto::HarnessId::Mock, + }; + let synced = crate::state::EngineHandle::bootstrap(boot.clone()) + .await + .expect("saved session opens its synced profile"); + assert_eq!(synced.engine_info().workspace_scope, WorkspaceScope::Synced); + + // Break the journal copy for the journaled chat, run the import. + let target_journals = dir + .path() + .join("orgs") + .join("org_1") + .join("user_1") + .join("journals"); + std::fs::remove_dir_all(&target_journals).unwrap(); + std::fs::write(&target_journals, b"obstruction").unwrap(); + let mut items = synced + .client() + .subscribe(methods::IMPORT_LOCAL_WORKSPACE, serde_json::json!({})) + .await + .expect("import stream"); + let mut summary = None; + while let Some(item) = items.recv().await { + summary = Some(item); + } + let summary = summary.expect("summary item"); + assert!( + import_summary_outcome(&summary).is_err(), + "injected failure must fail the summary: {summary}" + ); + + // "Later" + full app restart: stop this runtime, bootstrap a new one. + // The transient obstruction is gone by then (the recorded intent, not + // the live error, is what must survive the restart). + stop_synced_runtime(synced, port, dir.path()) + .await + .expect("runtime releases ownership"); + std::fs::remove_file(&target_journals).unwrap(); + let restarted = crate::state::EngineHandle::bootstrap(boot) + .await + .expect("restart boots the synced profile"); + assert_eq!( + restarted.engine_info().workspace_scope, + WorkspaceScope::Synced + ); + + // The restarted runtime still reports the pending retry… + let status = restarted + .client() + .call(methods::LOCAL_IMPORT_STATUS, serde_json::json!({})) + .await + .expect("status"); + assert_eq!( + status.get("pendingRetry").and_then(|b| b.as_bool()), + Some(true), + "pending retry must survive the restart: {status}" + ); + // …which restores the postponed failure state, whose menu action is + // the reopen entry — the retry path the pre-restart "Later" promised. + let restored = SyncFlow::ImportFailed { notice_open: false }; + assert_eq!( + account_menu_action(Some(WorkspaceScope::Synced), restored), + Some(AccountMenuAction::RestartPending) + ); + + // Retrying on the restarted runtime completes and clears the intent. + let mut items = restarted + .client() + .subscribe(methods::IMPORT_LOCAL_WORKSPACE, serde_json::json!({})) + .await + .expect("retry stream"); + let mut summary = None; + while let Some(item) = items.recv().await { + summary = Some(item); + } + assert!( + import_summary_outcome(&summary.expect("summary")).is_ok(), + "retry after clearing the obstruction is clean" + ); + let status = restarted + .client() + .call(methods::LOCAL_IMPORT_STATUS, serde_json::json!({})) + .await + .expect("status"); + assert_eq!( + status.get("pendingRetry").and_then(|b| b.as_bool()), + Some(false), + "clean retry clears the persisted intent: {status}" + ); + restarted.shutdown().await; + } + #[tokio::test] async fn signed_out_synced_runtime_stops_and_reboots_local() { let dir = tempfile::tempdir().unwrap(); @@ -6712,6 +7036,59 @@ mod tests { ); } + #[test] + fn probe_outcomes_never_swallow_the_recovery_path() { + let pending = serde_json::json!({ "pendingRetry": true, "availableChats": 2 }); + let clear = serde_json::json!({ "pendingRetry": false }); + + // A failed RPC defers — the guard must stay unset for a later retry. + assert_eq!( + import_probe_outcome(None, SyncFlow::Idle), + ImportProbeOutcome::Defer + ); + // Pending + idle restores. + assert_eq!( + import_probe_outcome(Some(&pending), SyncFlow::Idle), + ImportProbeOutcome::Restore + ); + // Pending that lands while the flow is busy is DEFERRED, not dropped. + assert_eq!( + import_probe_outcome(Some(&pending), SyncFlow::Importing { done: 1, total: 2 }), + ImportProbeOutcome::Defer + ); + // A definitive "nothing pending" settles the probe. + assert_eq!( + import_probe_outcome(Some(&clear), SyncFlow::Idle), + ImportProbeOutcome::Settle + ); + } + + #[test] + fn manual_import_row_is_the_markerless_recovery_route() { + // Synced + idle + importable rows: visible. + assert!(show_manual_import_row( + Some(WorkspaceScope::Synced), + SyncFlow::Idle, + 3 + )); + // Nothing importable, wrong scope, or a busy flow: hidden. + assert!(!show_manual_import_row( + Some(WorkspaceScope::Synced), + SyncFlow::Idle, + 0 + )); + assert!(!show_manual_import_row( + Some(WorkspaceScope::Local), + SyncFlow::Idle, + 3 + )); + assert!(!show_manual_import_row( + Some(WorkspaceScope::Synced), + SyncFlow::Importing { done: 0, total: 3 }, + 3 + )); + } + #[test] fn switch_lifecycle_survives_the_runtime_replacement_window() { let signed_in = AuthState::SignedIn {