Skip to content

Commit 6d8e1ab

Browse files
pmaxhoganclaude
andauthored
fix: clear the attention banner after a passing run and make source removal backend-aware (#310)
## Summary Two independent, unrelated bug fixes (PR6 of the v2.12.0 wave). **Issue #271 - drill/scrub attention banner stuck red** `needsAttention` (and the count it displays) summed failures across the whole loaded 10-run history window. At the monthly drill cadence, one bad run kept the red "N files could not be restored" banner up for ~10 subsequent passing drills, with no way for a later success to ever clear it (each drill draws a fresh time-seeded sample, so it can never "prove" an old failure is fine again by evidence). Applied the fix the linked review recommended: key the banner on the NEWEST loaded run only. The scrub panel used the identical sum-across-window pattern, so it got the same fix per the issue's explicit note that it applies there too. - `ui/src/stores/drill.ts`, `ui/src/stores/scrub.ts`: `failedTotal`/`unrecoverableTotal` (and therefore `needsAttention`) now read off `latest` only, not a `.reduce()` over the whole window. - Tests extended in `ui/src/__tests__/drill-panel.test.ts` and `ui/src/__tests__/scrub-panel.test.ts` covering both the panel and the store layer, including the exact failed-then-succeeded regression sequence from the issue. **Issue #227 - source removal + "delete backed-up files" broke on every non-Drive destination** `remove_source(delete_remote: true)` unconditionally rejected the request with `drive.unreachable` and Drive-specific wording ("Remove it from Google Drive directly"), on EVERY destination, not just S3 as reported - the feature was simply never implemented. Every `RemoteStore` implementation (Google Drive, S3, the local folder, SFTP) already provides `list_source_object_ids` + `trash` - the same primitives the integrity scrub and the executor's remote-existence audit already use - so this is now implemented for every backend rather than gated behind a capability flag; there was nothing backend-specific left to gate. - `src-tauri/src/commands/sources.rs`: `remove_source` now deletes the source's live remote objects FIRST (before any local row is touched) when `delete_remote` is set. Enumeration failure aborts with zero deletions (mirrors the audit's "abort with zero writes" rule); `trash` is idempotent, so a retry after a partial failure only re-deletes what's still actually there. The core logic (`delete_source_remote_objects_via`) is decoupled from `AppState` so it's directly unit-testable against a `RemoteStore` fake. - `ui/src/components/SourceTable.vue`: the remove-confirm flow previously failed *silently* on error (the promise rejection was simply unhandled). It now surfaces the stable error code inline and keeps the confirm panel open with the checkbox still ticked for a one-click retry, instead of leaving the user staring at an unremoved source with no explanation. - The checkbox label (`settings.sources.deleteRemoteLabel`, "Also delete the backed-up files from the destination") was already backend-neutral from an earlier pass; no copy change needed there. ## Test plan - [x] `cargo test -p driven-app --lib` - 445 passed, including 3 new tests covering: skip-when-nothing-recorded, delete-every-live-object across all four `BackendKind` values against a `RemoteStore` fake, and abort-with-zero-deletions on an enumeration failure. - [x] `cargo clippy -p driven-app --all-targets --all-features -- -D warnings` - clean. - [x] `cargo fmt -p driven-app -- --check` - clean. - [x] `pnpm -C ui run test:unit` - 794 passed (up from 793), including 3 new/extended tests for the banner fix and 1 new test for the removal-failure UI surface. - [x] `pnpm -C ui exec vue-tsc --noEmit` - clean. - [x] `pnpm -C ui run lint` - 0 errors (35 pre-existing unused-i18n-key warnings, unrelated to this change). - [x] `pnpm -C ui run format:check` - clean. README: checked, no changes needed (no user-visible feature list, destination table, or setting-location claim referenced either bug; the checkbox copy was already backend-neutral). Closes #271, closes #227 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> https://claude.ai/code/session_019xKUm9vH4ifb5LHR5szy1v Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2bb1e3d commit 6d8e1ab

7 files changed

Lines changed: 444 additions & 47 deletions

File tree

src-tauri/src/commands/sources.rs

Lines changed: 265 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -630,10 +630,13 @@ pub async fn update_source(
630630
/// `remove_source(source_id, delete_remote)` - remove a source (SPEC s11.2).
631631
///
632632
/// Deletes the `backup_sources` row (cascading its `file_state` + `pending_ops`)
633-
/// and reconfigures the owning orchestrator. `delete_remote` (trash the source's
634-
/// backed-up Drive content) is NOT performed in this slice (no standalone Drive
635-
/// store handle is exposed to IPC for a bulk remote trash); a `true` request is
636-
/// rejected so the caller is never told the remote was deleted when it was not.
633+
/// and reconfigures the owning orchestrator. `delete_remote` additionally
634+
/// deletes the source's backed-up files from its destination FIRST, before any
635+
/// local row is touched (issue #227) - this works on every destination backend
636+
/// (Google Drive, S3, the local folder, SFTP), not Drive only: it is plumbing
637+
/// over [`RemoteStore::list_source_object_ids`] + [`RemoteStore::trash`], the
638+
/// same backend-neutral primitives the integrity scrub and the remote-existence
639+
/// audit already use for every backend. See [`delete_source_remote_objects`].
637640
///
638641
/// R5-P1-1 (DATA-SAFETY): a source still holding a DURABLE pending recovery-phrase
639642
/// ack (a first encrypted source the user never saved the phrase for) is removed
@@ -647,17 +650,18 @@ pub async fn remove_source(
647650
source_id: SourceId,
648651
delete_remote: bool,
649652
) -> CommandResult<()> {
650-
if delete_remote {
651-
return Err(CommandError::with_code(
652-
ErrorCode::DriveUnreachable,
653-
"remote deletion on source removal is not available in this build; \
654-
the source's Drive content was left intact. Remove it from Google Drive directly.",
655-
));
656-
}
657-
658653
let row = find_source(state.state().as_ref(), source_id).await?;
659654
let account_id = row.account_id;
660655

656+
// Issue #227: delete the remote objects BEFORE any local row is touched, so
657+
// a failure here (enumeration or an individual delete) leaves the source in
658+
// place for the user to retry, exactly the "loud and atomic" behaviour the
659+
// issue asked to keep. Deletion is idempotent per object, so a retry after a
660+
// partial failure only re-deletes what is still actually there.
661+
if delete_remote {
662+
delete_source_remote_objects(&state, &row).await?;
663+
}
664+
661665
// R5-P1-1 (DATA-SAFETY): if this source still has a DURABLE pending
662666
// recovery-phrase ack (a first encrypted source the user never acked), a plain
663667
// `delete_source` would cascade the ack row but LEAVE the account's master-key
@@ -722,6 +726,83 @@ pub async fn remove_source(
722726
Ok(())
723727
}
724728

729+
/// Builds the account's real (or fake-mode) [`RemoteStore`] and deletes every
730+
/// remote object this source's backup still has on its destination, as part
731+
/// of `remove_source(delete_remote: true)` (issue #227). Thin AppState wiring
732+
/// over [`delete_source_remote_objects_via`], which carries the actual logic
733+
/// and is unit-tested directly against a [`RemoteStore`] fake.
734+
async fn delete_source_remote_objects(
735+
state: &State<'_, AppState>,
736+
source: &SourceRow,
737+
) -> CommandResult<()> {
738+
let account = find_account(state.state().as_ref(), source.account_id).await?;
739+
740+
// Issue #34: resolve the custom root CA / proxy for the one-off store's
741+
// client build, exactly as the picker and the restore path do.
742+
let ca = crate::commands::settings::load_custom_ca_config(state.state().as_ref())
743+
.await
744+
.unwrap_or_default();
745+
let proxy = crate::commands::settings::load_proxy_config(state.state().as_ref()).await?;
746+
let store = build_restore_store(state.inner(), &account, &ca, &proxy)?;
747+
748+
delete_source_remote_objects_via(state.state().as_ref(), store.as_ref(), source).await
749+
}
750+
751+
/// The actual delete-on-removal logic (issue #227), decoupled from
752+
/// [`AppState`] so it is directly unit-testable against a [`RemoteStore`]
753+
/// fake instead of needing a live account/credential/orchestrator harness.
754+
///
755+
/// Backend-neutral BY CONSTRUCTION: it is built entirely from
756+
/// [`RemoteStore::list_source_object_ids`] and [`RemoteStore::trash`], and
757+
/// EVERY [`RemoteStore`] implementation (`GoogleDriveStore`, `S3Store`,
758+
/// `LocalFsStore`, `SftpStore`) provides both - the same primitives the
759+
/// integrity scrub and the executor's remote-existence audit already use to
760+
/// enumerate a source's live objects. There is no per-backend branch here and
761+
/// none is needed: whichever store `delete_source_remote_objects` built for
762+
/// the account's configured `BackendKind`, this routes through it exactly the
763+
/// same way.
764+
///
765+
/// Enumerates the LIVE remote object set FIRST and aborts with zero deletions
766+
/// if that enumeration fails - mirroring the remote-existence audit's "abort
767+
/// with zero writes" rule (a truncated or failed listing must never be
768+
/// misread as "nothing to delete"). A source with nothing recorded in
769+
/// `file_state`/bundles skips the remote call entirely (nothing to delete, and
770+
/// this lets a source removed before its first upload cycle be cleaned up even
771+
/// if the account's credentials have since gone stale).
772+
///
773+
/// [`RemoteStore::trash`] is idempotent (an already-gone object is success), so
774+
/// a retry after a partial failure only re-deletes what genuinely remains.
775+
async fn delete_source_remote_objects_via(
776+
state: &dyn StateRepo,
777+
store: &dyn RemoteStore,
778+
source: &SourceRow,
779+
) -> CommandResult<()> {
780+
let recorded_files = state
781+
.list_file_state_drive_ids(source.id)
782+
.await
783+
.map_err(CommandError::from)?;
784+
let recorded_bundles = state
785+
.list_bundles_for_source(source.id)
786+
.await
787+
.map_err(CommandError::from)?;
788+
if recorded_files.is_empty() && recorded_bundles.is_empty() {
789+
tracing::debug!(target: TARGET, source_id = %source.id, "no recorded remote objects; skipping remote deletion");
790+
return Ok(());
791+
}
792+
793+
let live_ids = store
794+
.list_source_object_ids(&source.id.to_string(), &source.drive_context())
795+
.await
796+
.map_err(CommandError::from)?;
797+
798+
tracing::info!(target: TARGET, source_id = %source.id, object_count = live_ids.len(), "deleting source's remote objects before removal");
799+
for object_id in &live_ids {
800+
store.trash(object_id).await.map_err(CommandError::from)?;
801+
}
802+
803+
Ok(())
804+
}
805+
725806
/// `get_source_versioning(source_id)` - the per-source point-in-time versioning
726807
/// config (issue #36). Absent config decodes to the default (OFF).
727808
#[tauri::command]
@@ -2918,4 +2999,176 @@ mod tests {
29182999

29193000
let _ = std::fs::remove_dir_all(dir);
29203001
}
3002+
3003+
// --- Issue #227: delete-remote-on-removal is backend-neutral ------------
3004+
3005+
/// A minimal `file_state` row recording `drive_file_id` as already
3006+
/// uploaded, for the tests that need [`delete_source_remote_objects_via`]
3007+
/// to see the source as having something to delete.
3008+
fn file_state_row(
3009+
source_id: SourceId,
3010+
relative_path: &str,
3011+
drive_file_id: &str,
3012+
) -> driven_core::state::FileStateRow {
3013+
driven_core::state::FileStateRow {
3014+
source_id,
3015+
relative_path: relative_path
3016+
.to_string()
3017+
.try_into()
3018+
.expect("valid relative path"),
3019+
size: 3,
3020+
mtime_ns: 0,
3021+
hash_blake3: [0u8; 32],
3022+
drive_file_id: Some(drive_file_id.to_string()),
3023+
drive_md5: None,
3024+
encrypted_remote_path: None,
3025+
status: driven_core::types::FileStateStatus::Synced,
3026+
last_uploaded_at: None,
3027+
last_verified_at: None,
3028+
}
3029+
}
3030+
3031+
#[tokio::test]
3032+
async fn delete_source_remote_objects_skips_the_remote_call_when_nothing_was_uploaded() {
3033+
// A source removed before its first backup cycle (or one whose
3034+
// account credentials have since gone stale) has nothing recorded, so
3035+
// deletion must not even ATTEMPT a remote call - proven here by
3036+
// arming a fault that fails every `list_source_object_ids` call: if
3037+
// the skip did not fire, this would return `Err`.
3038+
let (repo, dir) = temp_repo().await;
3039+
let (_, source) = persist_source_on_backend(&repo, BackendKind::S3).await;
3040+
let store = driven_drive::fake::InMemoryRemoteStore::new().with_source_listing_broken();
3041+
3042+
delete_source_remote_objects_via(&repo, &store, &source)
3043+
.await
3044+
.expect("nothing recorded means nothing to delete, even with listing broken");
3045+
3046+
let _ = std::fs::remove_dir_all(dir);
3047+
}
3048+
3049+
#[tokio::test]
3050+
async fn delete_source_remote_objects_deletes_every_live_object_on_every_backend_kind() {
3051+
// Issue #227: the fix is that remote deletion works on EVERY
3052+
// destination, not Drive only. The deletion logic itself
3053+
// (`delete_source_remote_objects_via`) never branches on
3054+
// `BackendKind` - it only ever talks to the `RemoteStore` trait - so
3055+
// this proves the SAME code path deletes correctly regardless of
3056+
// which backend the source's account declares.
3057+
let (repo, dir) = temp_repo().await;
3058+
3059+
for kind in BackendKind::ALL.iter().copied() {
3060+
let (_, source) = persist_source_on_backend(&repo, kind).await;
3061+
repo.upsert_file_state(&file_state_row(source.id, "a.txt", "obj-1"))
3062+
.await
3063+
.expect("upsert file_state");
3064+
3065+
let store = driven_drive::fake::InMemoryRemoteStore::new();
3066+
let root = store.root_id().to_string();
3067+
let mut props = std::collections::HashMap::new();
3068+
props.insert(
3069+
driven_drive::fake::SOURCE_ID_KEY.to_string(),
3070+
source.id.to_string(),
3071+
);
3072+
store
3073+
.create(
3074+
&root,
3075+
"a.txt",
3076+
"application/octet-stream",
3077+
driven_drive::remote_store::UploadBody::Bytes(bytes::Bytes::from_static(b"hi")),
3078+
props,
3079+
)
3080+
.await
3081+
.expect("seed remote object");
3082+
3083+
let live_before = store
3084+
.list_source_object_ids(&source.id.to_string(), &source.drive_context())
3085+
.await
3086+
.expect("list before delete");
3087+
assert_eq!(
3088+
live_before.len(),
3089+
1,
3090+
"backend {kind}: seeded object must be live before deletion"
3091+
);
3092+
3093+
delete_source_remote_objects_via(&repo, &store, &source)
3094+
.await
3095+
.unwrap_or_else(|e| panic!("backend {kind}: deletion must succeed: {e}"));
3096+
3097+
let live_after = store
3098+
.list_source_object_ids(&source.id.to_string(), &source.drive_context())
3099+
.await
3100+
.expect("list after delete");
3101+
assert!(
3102+
live_after.is_empty(),
3103+
"backend {kind}: the object must no longer be live after deletion"
3104+
);
3105+
}
3106+
3107+
let _ = std::fs::remove_dir_all(dir);
3108+
}
3109+
3110+
#[tokio::test]
3111+
async fn delete_source_remote_objects_aborts_with_zero_deletions_when_enumeration_fails() {
3112+
// Mirrors the remote-existence audit's "abort with zero writes" rule:
3113+
// a listing that could not be completed must never be read as
3114+
// "nothing to delete". The object seeded below must survive
3115+
// untouched.
3116+
let (repo, dir) = temp_repo().await;
3117+
let (_, source) = persist_source_on_backend(&repo, BackendKind::Sftp).await;
3118+
repo.upsert_file_state(&file_state_row(source.id, "a.txt", "obj-1"))
3119+
.await
3120+
.expect("upsert file_state");
3121+
3122+
// `with_source_listing_broken` targets `list_source_object_ids` only
3123+
// (every other call, including `create`, keeps working), so the
3124+
// fault can be armed up front and the seed below still lands.
3125+
let broken = driven_drive::fake::InMemoryRemoteStore::new().with_source_listing_broken();
3126+
let root = broken.root_id().to_string();
3127+
let mut props = std::collections::HashMap::new();
3128+
props.insert(
3129+
driven_drive::fake::SOURCE_ID_KEY.to_string(),
3130+
source.id.to_string(),
3131+
);
3132+
let seeded = broken
3133+
.create(
3134+
&root,
3135+
"a.txt",
3136+
"application/octet-stream",
3137+
driven_drive::remote_store::UploadBody::Bytes(bytes::Bytes::from_static(b"hi")),
3138+
props,
3139+
)
3140+
.await
3141+
.expect("seed remote object on the broken store");
3142+
3143+
// The fake's fault is a plain unclassified `anyhow::bail!` (unlike a
3144+
// real backend's error, which the production `DriveErrorClassification`
3145+
// machinery maps to a specific code - `drive.unreachable` / `net.*` -
3146+
// via `CommandError::from`), so it lands on the generic fallback code.
3147+
// What matters here is that it IS an `Err`, and that nothing below got
3148+
// deleted as a result.
3149+
let err = delete_source_remote_objects_via(&repo, &broken, &source)
3150+
.await
3151+
.unwrap_err();
3152+
assert_eq!(
3153+
err.code,
3154+
ErrorCode::InternalBug,
3155+
"an unclassified enumeration failure must still surface as an error, not succeed"
3156+
);
3157+
3158+
// Nothing was deleted: the object is still present and NOT trashed.
3159+
// `list_folder_with_trashed` is a fault-free inherent test hook (the
3160+
// latched fault targets `list_source_object_ids` only), so this is a
3161+
// reliable post-condition check even against the broken store.
3162+
let entries = broken.list_folder_with_trashed(broken.root_id());
3163+
let entry = entries
3164+
.iter()
3165+
.find(|e| e.id == seeded.id)
3166+
.expect("seeded object must still exist");
3167+
assert!(
3168+
!entry.trashed,
3169+
"the seeded object must not have been trashed when enumeration aborted first"
3170+
);
3171+
3172+
let _ = std::fs::remove_dir_all(dir);
3173+
}
29213174
}

ui/src/__tests__/drill-panel.test.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,26 @@ describe("DrillHistoryPanel", () => {
123123
expect(some.find('[data-testid="drill-run-skipped"]').text()).toContain("2");
124124
});
125125

126-
it("raises a banner summarising files that could not be restored", async () => {
126+
it("raises a banner keyed on the newest run only, not summed across history", async () => {
127127
const wrapper = await mountPanel([
128128
run({ id: 2, outcome: "failed", verified: 2, failed: 1 }),
129129
run({ id: 1, outcome: "failed", verified: 1, failed: 2 }),
130130
]);
131-
// Summed across the loaded window: a file that would not come back two
132-
// drills ago is just as unrestorable today unless something was done.
133-
expect(wrapper.find('[data-testid="drill-attention"]').text()).toContain("3");
131+
// The newest run (id 2) failed 1 file; the older run's 2 failures do not
132+
// add in. See #271: summing across the whole loaded window kept the
133+
// banner red for months after the underlying problem cleared.
134+
expect(wrapper.find('[data-testid="drill-attention"]').text()).toContain("1");
135+
expect(wrapper.find('[data-testid="drill-attention"]').text()).not.toContain("3");
136+
});
137+
138+
it("clears the attention banner once a later drill passes (#271)", async () => {
139+
// The exact regression from #271: one bad run must not keep the banner
140+
// red once a subsequent run comes back clean.
141+
const wrapper = await mountPanel([
142+
run({ id: 2, outcome: "passed", verified: 3, failed: 0 }),
143+
run({ id: 1, outcome: "failed", verified: 1, failed: 2 }),
144+
]);
145+
expect(wrapper.find('[data-testid="drill-attention"]').exists()).toBe(false);
134146
});
135147

136148
it("renders counts and codes only, never a path", async () => {
@@ -182,7 +194,7 @@ describe("useDrillStore", () => {
182194
expect(store.inconclusive).toBe(false);
183195
});
184196

185-
it("exposes the newest run and the summed unrestorable count", async () => {
197+
it("exposes the newest run and keys failedTotal off it alone", async () => {
186198
invokeMock.mockResolvedValue([
187199
run({ id: 3, outcome: "failed", verified: 2, failed: 1 }),
188200
run({ id: 2, outcome: "failed", verified: 0, failed: 4 }),
@@ -192,12 +204,33 @@ describe("useDrillStore", () => {
192204
const store = useDrillStore();
193205
await store.refresh();
194206
expect(store.latest?.id).toBe(3);
195-
expect(store.failedTotal).toBe(5);
207+
// Only the newest run's failures count, not the 4 from run 2 - see #271.
208+
expect(store.failedTotal).toBe(1);
196209
expect(store.needsAttention).toBe(true);
197210
expect(store.loaded).toBe(true);
198211
expect(store.errorCode).toBeNull();
199212
});
200213

214+
it("clears needsAttention the moment the newest loaded run passes (#271)", async () => {
215+
// Regression coverage for the store layer, independent of the panel: a
216+
// failed run followed by a refresh that returns a passing newest run
217+
// must flip needsAttention back to false, not keep it pinned by history.
218+
invokeMock.mockResolvedValue([run({ id: 1, outcome: "failed", verified: 1, failed: 3 })]);
219+
setActivePinia(createPinia());
220+
const store = useDrillStore();
221+
await store.refresh();
222+
expect(store.needsAttention).toBe(true);
223+
expect(store.failedTotal).toBe(3);
224+
225+
invokeMock.mockResolvedValue([
226+
run({ id: 2, outcome: "passed", verified: 3, failed: 0 }),
227+
run({ id: 1, outcome: "failed", verified: 1, failed: 3 }),
228+
]);
229+
await store.refresh();
230+
expect(store.needsAttention).toBe(false);
231+
expect(store.failedTotal).toBe(0);
232+
});
233+
201234
it("reports inconclusive off the NEWEST run only", async () => {
202235
// An old inconclusive run is history; the question the flag answers is
203236
// "did the most recent drill actually prove anything".

0 commit comments

Comments
 (0)