Skip to content

Commit 90cde5c

Browse files
pmaxhoganclaude
andauthored
feat(core): record a backup_done activity row when a run completes (#160)
## Summary - New `backup_done` Info activity row written at the all-ops-succeeded point of a cycle (same site as `deep_verify_done`), with `file_count` = files uploaded (bundles count their members via `exec_progress_from`) and `bytes` = bytes uploaded. - Scoping mirrors #149's scan rows: ALWAYS for a manual Run Now (the feed now reads scan started -> scan complete -> uploads -> Backup complete, even when nothing needed uploading), scheduled ticks only when the run actually executed ops - idle 10-minute ticks add no noise. - A run with failed ops writes no completion row (it defers the timestamp advance and stays due for retry; claiming completion would be a lie). - The summary/telemetry aggregates filter to `upload_done`/`bundle_upload`, so this byte-carrying row cannot double-count. - Locale: `activity.events.backup_done` = "Backup complete". ## Testing - 3 new orchestrator tests: totals on a worked scheduled cycle; idle-scheduled quiet but manual-with-nothing logs 0/0; failed ops suppress the row. - `cargo test -p driven-core --lib` 359/359; clippy clean; label vitest + prettier + eslint green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JLB3E2Jm7knNJd37fVpH8X Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 11af7ea commit 90cde5c

3 files changed

Lines changed: 149 additions & 0 deletions

File tree

crates/driven-core/src/orchestrator.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,6 +1661,29 @@ impl SyncOrchestrator {
16611661
tracing::warn!(target: TARGET, source_id = %source.id, %err, "failed to record deep_verify_done activity row (telemetry count may undercount)");
16621662
}
16631663
}
1664+
// "Backup complete" row closing the run in the activity feed (before
1665+
// this, a run simply trailed off after its last per-file row with
1666+
// nothing saying it finished). Written only at this all-ops-succeeded
1667+
// point, carrying the run's upload totals. Scoping mirrors the scan
1668+
// rows above: a user-initiated cycle ALWAYS logs it (a "Run now" that
1669+
// found nothing to upload still ends with a visible result), a
1670+
// scheduled cycle only when it actually executed ops - the idle
1671+
// 10-minute ticks stay quiet. The summary/telemetry aggregates filter
1672+
// to upload_done/bundle_upload, so this byte-carrying row cannot
1673+
// double-count there.
1674+
if user_initiated || !outcomes.is_empty() {
1675+
let final_progress = exec_progress_from(&summary, &outcomes);
1676+
self.record_activity(NewActivity {
1677+
ts: now,
1678+
source_id: Some(source.id),
1679+
level: ActivityLevel::Info,
1680+
event_type: "backup_done".to_string(),
1681+
file_count: Some(final_progress.files_done),
1682+
bytes: Some(final_progress.bytes_done),
1683+
message: None,
1684+
})
1685+
.await;
1686+
}
16641687
if let Err(err) = self.state.mark_account_synced(self.account_id, now).await {
16651688
tracing::warn!(target: TARGET, account_id = %self.account_id, %err, "failed to persist account last_synced_at");
16661689
}
@@ -5777,6 +5800,128 @@ mod tests {
57775800
);
57785801
}
57795802

5803+
#[tokio::test]
5804+
async fn cycle_records_backup_done_with_totals() {
5805+
// The reported gap: the activity feed had no row marking a run's
5806+
// completion - it just trailed off after the last per-file row. A cycle
5807+
// whose ops all succeeded must close with a `backup_done` row carrying
5808+
// the run's upload totals. A SCHEDULED cycle that actually executed ops
5809+
// logs it too (the quiet-scheduled scoping only silences idle ticks).
5810+
let account = AccountId::new_v4();
5811+
let dir = tempfile::tempdir().unwrap();
5812+
seed_files(dir.path(), 3);
5813+
let src = source_in(account, dir.path());
5814+
let src_id = src.id;
5815+
let state = Arc::new(FakeState::with_sources(vec![src]));
5816+
let orch = SyncOrchestrator::new(
5817+
account,
5818+
state.clone(),
5819+
Arc::new(RecordingExecutor::default()),
5820+
Arc::new(FakePowerSource::new(power_on_ac())),
5821+
Arc::new(FakeNet::online()),
5822+
Arc::new(FakeClock::new()),
5823+
OrchestratorConfig::default(),
5824+
);
5825+
5826+
orch.run_cycle(TickSource::Scheduled).await.unwrap();
5827+
5828+
let rows = state.activity_rows();
5829+
let done: Vec<_> = rows
5830+
.iter()
5831+
.filter(|r| r.event_type == "backup_done")
5832+
.collect();
5833+
assert_eq!(done.len(), 1, "one backup_done row per completed run");
5834+
assert_eq!(done[0].source_id, Some(src_id));
5835+
assert_eq!(done[0].level, ActivityLevel::Info);
5836+
assert_eq!(
5837+
done[0].file_count,
5838+
Some(3),
5839+
"backup_done carries the files the run uploaded"
5840+
);
5841+
assert_eq!(
5842+
done[0].bytes,
5843+
Some(3),
5844+
"backup_done carries the bytes the run uploaded (3 one-byte seeds)"
5845+
);
5846+
}
5847+
5848+
#[tokio::test]
5849+
async fn idle_scheduled_cycle_writes_no_backup_done_but_manual_does() {
5850+
// Scoping mirrors the scan rows: an idle 10-minute tick (empty plan,
5851+
// nothing executed) must NOT add a completion row per tick (~144/day of
5852+
// noise), but a user-initiated "Run now" always ends with a visible
5853+
// result - even when there was nothing to upload.
5854+
let account = AccountId::new_v4();
5855+
let dir = tempfile::tempdir().unwrap(); // empty source -> empty plan
5856+
let src = source_in(account, dir.path());
5857+
let state = Arc::new(FakeState::with_sources(vec![src]));
5858+
let orch = SyncOrchestrator::new(
5859+
account,
5860+
state.clone(),
5861+
Arc::new(RecordingExecutor::default()),
5862+
Arc::new(FakePowerSource::new(power_on_ac())),
5863+
Arc::new(FakeNet::online()),
5864+
Arc::new(FakeClock::new()),
5865+
OrchestratorConfig::default(),
5866+
);
5867+
5868+
orch.run_cycle(TickSource::Scheduled).await.unwrap();
5869+
assert!(
5870+
state
5871+
.activity_rows()
5872+
.iter()
5873+
.all(|r| r.event_type != "backup_done"),
5874+
"an idle scheduled cycle stays quiet"
5875+
);
5876+
5877+
orch.run_cycle(TickSource::Manual).await.unwrap();
5878+
let rows = state.activity_rows();
5879+
let done: Vec<_> = rows
5880+
.iter()
5881+
.filter(|r| r.event_type == "backup_done")
5882+
.collect();
5883+
assert_eq!(
5884+
done.len(),
5885+
1,
5886+
"a manual run logs its completion even with nothing to upload"
5887+
);
5888+
assert_eq!(done[0].file_count, Some(0));
5889+
assert_eq!(done[0].bytes, Some(0));
5890+
}
5891+
5892+
#[tokio::test]
5893+
async fn failed_ops_suppress_backup_done() {
5894+
// A run with a failed op defers the timestamp advance and stays due for
5895+
// retry - claiming "Backup complete" for it would be a lie. The error
5896+
// rows recorded per-op are the visible evidence instead.
5897+
let account = AccountId::new_v4();
5898+
let dir = tempfile::tempdir().unwrap();
5899+
seed_files(dir.path(), 2);
5900+
let src = source_in(account, dir.path());
5901+
let state = Arc::new(FakeState::with_sources(vec![src]));
5902+
let exec = Arc::new(RecordingExecutor::default());
5903+
exec.fail_ops.store(1, Ordering::SeqCst);
5904+
let orch = SyncOrchestrator::new(
5905+
account,
5906+
state.clone(),
5907+
exec,
5908+
Arc::new(FakePowerSource::new(power_on_ac())),
5909+
Arc::new(FakeNet::online()),
5910+
Arc::new(FakeClock::new()),
5911+
OrchestratorConfig::default(),
5912+
);
5913+
5914+
orch.run_cycle(TickSource::Manual).await.unwrap();
5915+
5916+
assert!(
5917+
state
5918+
.activity_rows()
5919+
.iter()
5920+
.all(|r| r.event_type != "backup_done"),
5921+
"a run with failed ops must not claim completion"
5922+
);
5923+
}
5924+
57805925
#[tokio::test]
57815926
async fn scan_streams_a_rising_file_count_to_the_ui() {
57825927
// The other half of the bug: `Scanning { scanned }` was broadcast ONCE

ui/src/__tests__/activity-event-label.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ describe("activityEventLabel (R1-P2-3)", () => {
3535
// backend-emitted types with no curated label.
3636
expect(label("hook.pre")).toBe("Pre-backup hook");
3737
expect(label("hook.post")).toBe("Post-backup hook");
38+
// The run-completion row the orchestrator writes when a cycle's ops all
39+
// succeeded (the feed used to trail off after the last per-file row).
40+
expect(label("backup_done")).toBe("Backup complete");
3841
});
3942

4043
it("falls back to errors.<code>.short for error/skip code event types", () => {

ui/src/locales/en-US.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@
405405
"trash_done": "Removed",
406406
"scan_started": "Scan started",
407407
"scan_done": "Scan complete",
408+
"backup_done": "Backup complete",
408409
"deep_verify_done": "Deep verify complete",
409410
"update_applied": "App updated",
410411
"paused": "Paused",

0 commit comments

Comments
 (0)