Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 124 additions & 6 deletions src-tauri/src/assembly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1134,11 +1134,35 @@ fn spawn_event_bridge(
);
}
}
BridgeAction::SourceProgress {
source_id,
progress,
} => {
// The `Executing` state transition carries a ZEROED
// ExecProgress and never fires again for that source, so the
// moving counters only exist on these ticks. Forwarding them
// is what makes the top-of-app bar determinate instead of an
// indeterminate sweep for the whole upload. The account id
// comes from this bridge (the core event carries only the
// source) so the webview can attribute the tick.
let payload = SourceProgressEvent {
account_id: account_id.to_string(),
source_id: source_id.to_string(),
progress,
};
if let Err(err) = events::emit_sync_source_progress(&app, &payload) {
tracing::debug!(
target: TARGET,
account_id = %account_id,
%err,
"emit sync:source_progress failed"
);
}
}
BridgeAction::Ignore => {
// Progress / Power / Network events: not bridged to the
// webview in M5 (the progress DTO lands with a later
// milestone). The tray's coarse state is driven by
// StateChanged above.
// Power / Network events: not bridged to the webview - the
// tray's coarse state is driven by StateChanged above and
// the gate outcome surfaces as a `Paused` state anyway.
}
BridgeAction::Stop => {
tracing::debug!(
Expand Down Expand Up @@ -1173,7 +1197,14 @@ enum BridgeAction {
/// `activity:lagged` so the webview reconciles from the durable
/// `activity_log`. No durable row is lost.
ActivityReconcile { skipped: u64 },
/// A non-bridged event (progress / power / network); do nothing.
/// Emit `sync:source_progress` with one execution-progress tick, so the
/// webview's progress bar moves during an upload (the `Executing` state
/// transition only ever carries `ExecProgress::zero()`).
SourceProgress {
source_id: driven_core::types::SourceId,
progress: driven_core::types::ExecProgress,
},
/// A non-bridged event (power / network); do nothing.
Ignore,
/// The broadcast closed (orchestrator dropped); end the bridge.
Stop,
Expand All @@ -1192,6 +1223,13 @@ fn classify_bridge_event(
BridgeAction::NeedsReauth { account_id }
}
Ok(OrchestratorEvent::ActivityWritten { entry }) => BridgeAction::ActivityNew { entry },
Ok(OrchestratorEvent::Progress {
source_id,
progress,
}) => BridgeAction::SourceProgress {
source_id,
progress,
},
Ok(_) => BridgeAction::Ignore,
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
BridgeAction::ActivityReconcile { skipped }
Expand All @@ -1210,13 +1248,24 @@ struct AccountSyncStatusEvent {
state: driven_core::types::OrchestratorState,
}

/// The per-tick `sync:source_progress` payload (SPEC s11.7
/// `{ source_id, progress }`, plus the `account_id` the bridge knows and the
/// core event does not). snake_case on the wire like the rest of the M5 sync
/// DTOs and like `ExecProgress` itself, so the webview parses one convention.
#[derive(serde::Serialize, Clone)]
struct SourceProgressEvent {
account_id: String,
source_id: String,
progress: driven_core::types::ExecProgress,
}

#[cfg(test)]
mod tests {
use super::{classify_bridge_event, BridgeAction};
use driven_core::orchestrator::OrchestratorConfig;
use driven_core::state::sqlite::SqliteStateRepo;
use driven_core::state::StateRepo;
use driven_core::types::{ActivityEntry, OrchestratorEvent};
use driven_core::types::{AccountId, ActivityEntry, ExecProgress, OrchestratorEvent, SourceId};
use tokio::sync::broadcast::error::RecvError;

/// M7-P1-1: a broadcast `Lagged` MUST classify as an `ActivityReconcile`
Expand Down Expand Up @@ -1260,6 +1309,73 @@ mod tests {
}
}

/// An execution-progress tick MUST classify as `SourceProgress` (carrying the
/// source + the moving counters unchanged) so the bridge emits
/// `sync:source_progress`. This is the fix for the bar staying indeterminate:
/// the `Executing` state transition carries only `ExecProgress::zero()`, so
/// dropping these ticks (the old `Ignore`) left the webview with nothing but
/// zeros and therefore no percent.
#[test]
fn progress_classifies_as_source_progress() {
let source_id = SourceId::new_v4();
let progress = ExecProgress {
files_done: 3,
files_total: 10,
bytes_done: 512,
bytes_total: 2048,
trashes_done: 1,
trashes_total: 2,
errors: 0,
};
match classify_bridge_event(Ok(OrchestratorEvent::Progress {
source_id,
progress,
})) {
BridgeAction::SourceProgress {
source_id: got_source,
progress: got,
} => {
assert_eq!(got_source, source_id);
assert_eq!(got, progress, "the tick must be forwarded unchanged");
}
other => panic!(
"Progress must bridge, got {:?}",
BridgeActionKind::of(&other)
),
}
}

/// The `sync:source_progress` payload must serialize snake_case (matching
/// the M5 sync DTOs and `ExecProgress` itself) and carry the account id the
/// bridge adds, so the webview can attribute a tick to one orchestrator and
/// parse `progress` with the same reader it uses for the `executing` state.
#[test]
fn source_progress_payload_is_snake_case_with_account() {
let account_id = AccountId::new_v4();
let source_id = SourceId::new_v4();
let payload = super::SourceProgressEvent {
account_id: account_id.to_string(),
source_id: source_id.to_string(),
progress: ExecProgress {
files_done: 2,
files_total: 4,
bytes_done: 100,
bytes_total: 400,
trashes_done: 0,
trashes_total: 0,
errors: 0,
},
};
let json = serde_json::to_value(&payload).expect("serialize");
assert_eq!(
json["account_id"],
serde_json::json!(account_id.to_string())
);
assert_eq!(json["source_id"], serde_json::json!(source_id.to_string()));
assert_eq!(json["progress"]["files_done"], serde_json::json!(2));
assert_eq!(json["progress"]["bytes_total"], serde_json::json!(400));
}

/// A closed broadcast classifies as `Stop` so the bridge ends (no orphaned
/// task); a non-bridged event (`Power`) classifies as `Ignore`.
#[test]
Expand All @@ -1284,6 +1400,7 @@ mod tests {
NeedsReauth,
ActivityNew,
ActivityReconcile,
SourceProgress,
Ignore,
Stop,
}
Expand All @@ -1294,6 +1411,7 @@ mod tests {
BridgeAction::NeedsReauth { .. } => Self::NeedsReauth,
BridgeAction::ActivityNew { .. } => Self::ActivityNew,
BridgeAction::ActivityReconcile { .. } => Self::ActivityReconcile,
BridgeAction::SourceProgress { .. } => Self::SourceProgress,
BridgeAction::Ignore => Self::Ignore,
BridgeAction::Stop => Self::Stop,
}
Expand Down
29 changes: 24 additions & 5 deletions src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@ use tauri::{AppHandle, Emitter};
/// `sync:status_changed` - global sync status changed (payload:
/// `GlobalSyncStatus`, SPEC s11.7).
pub const EVENT_SYNC_STATUS_CHANGED: &str = "sync:status_changed";
/// `sync:source_progress` - per-source progress (payload:
/// `{ source_id, progress }`, SPEC s11.7).
/// `sync:source_progress` - per-source execution progress (payload:
/// `{ account_id, source_id, progress }`, SPEC s11.7).
///
/// Reserved for M6: the per-source progress DTO + the bridge that emits it land
/// with the M6 IPC layer (the M5 event bridge only forwards `StateChanged`).
#[allow(dead_code)]
/// The orchestrator transitions to `Executing { progress: ExecProgress::zero() }`
/// exactly ONCE per source and then streams the moving counters as separate
/// `OrchestratorEvent::Progress` ticks. Those ticks used to stop at the event
/// bridge, so the webview only ever saw the zeroed snapshot embedded in the
/// state and the top-of-app bar stayed indeterminate for the whole upload. The
/// bridge now forwards every tick on this channel so the bar is determinate.
/// The account id is added by the bridge (the core event carries only the
/// source) so the webview can attribute the tick to the right orchestrator.
pub const EVENT_SYNC_SOURCE_PROGRESS: &str = "sync:source_progress";
/// `activity:new` - a new activity-log entry (payload: `ActivityEntry`,
/// SPEC s11.7).
Expand Down Expand Up @@ -110,6 +115,20 @@ pub fn emit_sync_status_changed<P: Serialize + Clone>(
app.emit(EVENT_SYNC_STATUS_CHANGED, status)
}

/// Broadcast `sync:source_progress` with one execution-progress tick (SPEC
/// s11.7).
///
/// Thin wrapper over [`Emitter::emit`], mirroring `emit_sync_status_changed`, so
/// the event bridge cannot typo the channel. The payload is the bridge's
/// `SourceProgressEvent` (`{ account_id, source_id, progress }`, snake_case like
/// the rest of the M5 sync DTOs).
pub fn emit_sync_source_progress<P: Serialize + Clone>(
app: &AppHandle,
progress: &P,
) -> tauri::Result<()> {
app.emit(EVENT_SYNC_SOURCE_PROGRESS, progress)
}

/// Broadcast `activity:new` with the new activity entry (SPEC s11.7).
///
/// M7 (activity dashboard): the event bridge calls this on every
Expand Down
5 changes: 3 additions & 2 deletions ui/src/__tests__/app-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,9 @@ describe("App shell", () => {
it("subscribes + hydrates the updater, progress and pause stores on boot", async () => {
await mountAppAt("/activity");
// Three updater events (available, download_progress, downloaded) + the
// sync-status event + the pause event registered.
expect(listenMock).toHaveBeenCalledTimes(5);
// progress store's TWO sync events (status_changed for the phase,
// source_progress for the moving counters) + the pause event registered.
expect(listenMock).toHaveBeenCalledTimes(6);
expect(invokeMock).toHaveBeenCalledWith("get_pending_update_info", undefined);
expect(invokeMock).toHaveBeenCalledWith("get_sync_status", undefined);
expect(invokeMock).toHaveBeenCalledWith("get_pause_state", undefined);
Expand Down
54 changes: 54 additions & 0 deletions ui/src/__tests__/global-progress-bar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ function executing(p: Partial<ExecProgress>): OrchestratorState {
function perAccount(accountId: string, state: OrchestratorState) {
return { account_id: accountId, state };
}
/** One `sync:source_progress` tick - the ONLY carrier of the moving counters
* (the `executing` transition itself carries `ExecProgress::zero()`). */
function tick(accountId: string, p: Partial<ExecProgress>) {
return {
account_id: accountId,
source_id: "src-1",
progress: {
files_done: 0,
files_total: 0,
bytes_done: 0,
bytes_total: 0,
trashes_done: 0,
trashes_total: 0,
errors: 0,
...p,
},
};
}

function mountBar() {
const pinia = createPinia();
Expand Down Expand Up @@ -167,6 +185,25 @@ describe("GlobalProgressBar", () => {
expect(wrapper.find(PHASE_LABEL).text()).toBe("Backing up - 50%");
});

it("names the file counts once the live ticks carry a total", async () => {
const { store, wrapper } = mountBar();
store.ingest(perAccount("a", executing({})));
store.ingestProgress(tick("a", { bytes_done: 512, bytes_total: 1024 }));
await wrapper.vm.$nextTick();
// No file total yet (a delete-only plan uploads nothing): bare percent.
expect(wrapper.find(PHASE_LABEL).text()).toBe("Backing up - 50%");

store.ingestProgress(
tick("a", { bytes_done: 512, bytes_total: 1024, files_done: 1234, files_total: 3000 })
);
await wrapper.vm.$nextTick();
// Locale-grouped counts, so the run's scale is legible at a glance.
expect(wrapper.find(PHASE_LABEL).text()).toBe("Backing up - 50% (1,234 of 3,000 files)");
expect(wrapper.find(BAR).attributes("aria-label")).toBe(
"Backing up - 50% (1,234 of 3,000 files)"
);
});

it("renders no readout at all while idle", async () => {
const { store, wrapper } = mountBar();
store.ingest(perAccount("a", idle()));
Expand All @@ -175,6 +212,23 @@ describe("GlobalProgressBar", () => {
});
});

// The bug this fixes: in production the `executing` state ALWAYS arrives with
// a zeroed ExecProgress, so before the ticks were bridged the bar rendered the
// indeterminate sweep for the entire upload.
it("goes from indeterminate to determinate when the first live tick lands", async () => {
const { store, wrapper } = mountBar();
store.ingest(perAccount("a", executing({})));
await wrapper.vm.$nextTick();
expect(wrapper.find(INDETERMINATE).exists()).toBe(true);
expect(wrapper.find(BAR).attributes("aria-valuenow")).toBeUndefined();

store.ingestProgress(tick("a", { bytes_done: 300, bytes_total: 1200 }));
await wrapper.vm.$nextTick();
expect(wrapper.find(INDETERMINATE).exists()).toBe(false);
expect(wrapper.find(BAR).attributes("aria-valuenow")).toBe("25");
expect(wrapper.find(BAR).find("div").attributes("style")).toContain("width: 25%");
});

it("appears and disappears reactively as a run starts then finishes", async () => {
const { store, wrapper } = mountBar();
expect(wrapper.find(BAR).exists()).toBe(false);
Expand Down
Loading