Files
doctate/client-desktop/src/uploader.rs
T
Brummel 7637596598 feat: Add poller and case list to desktop client
This commit introduces a new poller to the desktop client, responsible
for fetching case markers and their one-liners. It also integrates a
case list UI, allowing users to view and interact with their cases.

Key changes include:

- **New Dependencies**: Added `doctate-client-core`, `time`, and
  `webbrowser` to `Cargo.lock`.
- **Background Task Spawning**: Refactored `spawn_uploader` to
  `spawn_background` to include the new `OnelinerPoller` and its
  dependencies.
- **Case Store Integration**: The `DoctateApp` now initializes and uses
  a `CaseStore` to manage case markers.
- **UI Enhancements**:
    - A new `render_case_list` function displays recent cases with their
      last activity time and one-liner.
    - Buttons to "Continue" recording for a case and "Open" the case in
      a web browser are added.
    - A staleness indicator for the poller's connection is displayed.
- **State Management**:
    - The `AppState` is updated to reflect the new UI elements and
      interactions.
    - Actions like `Continue` and `OpenWeb` are handled.
- **Configuration**: Poll interval and window hours are now configurable
  and used by the poller.
- **Error Handling**: Improved error handling for saving configuration
  and background task operations.
- **Code Cleanup**: Minor refactoring and documentation updates.
2026-04-18 10:12:38 +02:00

424 lines
14 KiB
Rust

//! Async uploader with persistent on-disk queue and exponential-backoff
//! retry. Recordings live in the pending directory as `{stem}.m4a` plus
//! a sidecar `{stem}.meta.json`. The worker replays anything it finds
//! there at startup, then processes new submissions as they arrive.
use std::path::{Path, PathBuf};
use std::time::Duration;
use doctate_common::ack::{AckResponse, AckStatus};
use doctate_common::constants::{
API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT,
UPLOAD_PATH,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::mpsc;
use tracing::{info, warn};
use uuid::Uuid;
use crate::config::Config;
const INITIAL_BACKOFF: Duration = Duration::from_secs(2);
const MAX_BACKOFF: Duration = Duration::from_secs(60);
/// A recording waiting to be uploaded. The `file` path is derived from
/// the sidecar JSON's location, never from the JSON content itself —
/// that way the sidecar stays valid even if the pending directory is
/// renamed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingUpload {
pub case_id: Uuid,
pub recorded_at: String,
#[serde(skip, default)]
pub file: PathBuf,
}
/// Lifecycle events emitted to the UI for a single upload attempt chain.
#[derive(Debug)]
pub enum UploadEvent {
Started(Uuid),
Succeeded {
case_id: Uuid,
status: AckStatus,
},
Failed {
case_id: Uuid,
reason: String,
will_retry: bool,
},
}
#[derive(Debug, Error)]
pub enum UploaderError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("serialize sidecar: {0}")]
Serialize(#[from] serde_json::Error),
}
/// Handle to the uploader's worker task. Drop does NOT shut down the
/// worker; it stays alive as long as the `submit_tx` is kept.
pub struct Uploader {
submit_tx: mpsc::UnboundedSender<PendingUpload>,
}
impl Uploader {
/// Spawn the worker task. Must be called inside a Tokio runtime
/// context (`Runtime::enter()` guard or `#[tokio::main]`).
///
/// Re-scans `pending_dir` at startup so leftover recordings from
/// a prior run are replayed automatically.
pub fn spawn(
config: Config,
pending_dir: PathBuf,
) -> (Self, mpsc::UnboundedReceiver<UploadEvent>) {
let (submit_tx, submit_rx) = mpsc::unbounded_channel();
let (events_tx, events_rx) = mpsc::unbounded_channel();
tokio::spawn(run_worker(config, pending_dir, submit_rx, events_tx));
(Self { submit_tx }, events_rx)
}
/// Submit a pending upload for processing. Non-blocking. Fails only
/// if the worker has been dropped (should not happen in normal
/// operation).
pub fn submit(
&self,
upload: PendingUpload,
) -> Result<(), mpsc::error::SendError<PendingUpload>> {
self.submit_tx.send(upload)
}
}
/// Path of the sidecar JSON for a given m4a file — same directory,
/// `{stem}.meta.json`.
pub fn sidecar_path_for(m4a: &Path) -> PathBuf {
let stem = m4a
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let dir = m4a.parent().unwrap_or_else(|| Path::new("."));
dir.join(format!("{stem}.meta.json"))
}
/// Persist a pending upload's metadata next to its m4a file.
pub fn write_sidecar(upload: &PendingUpload) -> Result<(), UploaderError> {
let sidecar = sidecar_path_for(&upload.file);
if let Some(parent) = sidecar.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(upload)?;
std::fs::write(&sidecar, json)?;
Ok(())
}
/// Scan `pending_dir` for m4a files with matching sidecars. Orphans
/// (m4a without sidecar, or unreadable sidecar) are logged and skipped
/// rather than propagated as errors — one broken file should not take
/// down recovery of the rest.
pub fn scan_pending_dir(pending_dir: &Path) -> Vec<PendingUpload> {
let entries = match std::fs::read_dir(pending_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(e) => {
warn!(dir = ?pending_dir, error = %e, "scan pending dir failed");
return Vec::new();
}
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
continue;
}
match read_pending(&path) {
Ok(upload) => out.push(upload),
Err(e) => warn!(path = ?path, error = %e, "skipping pending entry"),
}
}
out
}
fn read_pending(m4a: &Path) -> Result<PendingUpload, UploaderError> {
let sidecar = sidecar_path_for(m4a);
let json = std::fs::read_to_string(&sidecar)?;
let mut upload: PendingUpload = serde_json::from_str(&json)?;
upload.file = m4a.to_path_buf();
Ok(upload)
}
async fn run_worker(
config: Config,
pending_dir: PathBuf,
mut submit_rx: mpsc::UnboundedReceiver<PendingUpload>,
events_tx: mpsc::UnboundedSender<UploadEvent>,
) {
let client = reqwest::Client::new();
let startup = scan_pending_dir(&pending_dir);
if !startup.is_empty() {
info!(count = startup.len(), "replaying pending uploads");
}
for pending in startup {
upload_with_retry(&client, &config, pending, &events_tx).await;
}
while let Some(pending) = submit_rx.recv().await {
upload_with_retry(&client, &config, pending, &events_tx).await;
}
}
async fn upload_with_retry(
client: &reqwest::Client,
config: &Config,
upload: PendingUpload,
events_tx: &mpsc::UnboundedSender<UploadEvent>,
) {
let mut backoff = INITIAL_BACKOFF;
loop {
let _ = events_tx.send(UploadEvent::Started(upload.case_id));
match attempt_upload(client, config, &upload).await {
Ok(ack) => {
let _ = tokio::fs::remove_file(&upload.file).await;
let _ = tokio::fs::remove_file(&sidecar_path_for(&upload.file)).await;
let _ = events_tx.send(UploadEvent::Succeeded {
case_id: upload.case_id,
status: ack.status,
});
return;
}
Err(ErrorKind::Terminal(reason)) => {
let _ = events_tx.send(UploadEvent::Failed {
case_id: upload.case_id,
reason,
will_retry: false,
});
return;
}
Err(ErrorKind::Transient(reason)) => {
let _ = events_tx.send(UploadEvent::Failed {
case_id: upload.case_id,
reason,
will_retry: true,
});
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
}
}
}
}
enum ErrorKind {
Transient(String),
Terminal(String),
}
async fn attempt_upload(
client: &reqwest::Client,
config: &Config,
upload: &PendingUpload,
) -> Result<AckResponse, ErrorKind> {
let audio = tokio::fs::read(&upload.file)
.await
.map_err(|e| ErrorKind::Terminal(format!("read audio file: {e}")))?;
let file_name = upload
.file
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "recording.m4a".to_string());
let audio_part = reqwest::multipart::Part::bytes(audio)
.file_name(file_name)
.mime_str(CONTENT_TYPE_AUDIO_MP4)
.expect("static MIME string is valid");
let form = reqwest::multipart::Form::new()
.text(FIELD_CASE_ID, upload.case_id.to_string())
.text(FIELD_RECORDED_AT, upload.recorded_at.clone())
.part(FIELD_AUDIO, audio_part);
let url = format!("{}{}", config.server_url.trim_end_matches('/'), UPLOAD_PATH);
let resp = client
.post(&url)
.header(API_KEY_HEADER, &config.api_key)
.multipart(form)
.send()
.await
.map_err(|e| ErrorKind::Transient(format!("network: {e}")))?;
let status = resp.status();
if status.is_success() {
resp.json::<AckResponse>()
.await
.map_err(|e| ErrorKind::Terminal(format!("parse ack: {e}")))
} else if status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error() {
let body = resp.text().await.unwrap_or_default();
Err(ErrorKind::Transient(format!("HTTP {status}: {body}")))
} else {
let body = resp.text().await.unwrap_or_default();
Err(ErrorKind::Terminal(format!("HTTP {status}: {body}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use wiremock::matchers::{header, method, path as wpath};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sample_pending(dir: &Path) -> PendingUpload {
let file = dir.join("sample.m4a");
std::fs::write(&file, b"dummy audio").unwrap();
PendingUpload {
case_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
recorded_at: "2026-04-15T10:32:00Z".into(),
file,
}
}
fn ack_body(status: AckStatus) -> AckResponse {
AckResponse {
case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
recorded_at: "2026-04-15T10:32:00Z".into(),
status,
}
}
#[test]
fn sidecar_roundtrip_via_scan() {
let tmp = TempDir::new().unwrap();
let upload = sample_pending(tmp.path());
write_sidecar(&upload).unwrap();
let found = scan_pending_dir(tmp.path());
assert_eq!(found.len(), 1);
assert_eq!(found[0].case_id, upload.case_id);
assert_eq!(found[0].recorded_at, upload.recorded_at);
assert_eq!(found[0].file, upload.file);
}
#[test]
fn scan_ignores_m4a_without_sidecar() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("orphan.m4a"), b"x").unwrap();
let found = scan_pending_dir(tmp.path());
assert!(found.is_empty());
}
#[test]
fn scan_returns_empty_for_missing_dir() {
let tmp = TempDir::new().unwrap();
let missing = tmp.path().join("does-not-exist");
assert!(scan_pending_dir(&missing).is_empty());
}
#[tokio::test]
async fn successful_upload_deletes_files_and_emits_events() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(wpath(UPLOAD_PATH))
.and(header(API_KEY_HEADER, "test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(ack_body(AckStatus::Received)))
.mount(&server)
.await;
let tmp = TempDir::new().unwrap();
let upload = sample_pending(tmp.path());
write_sidecar(&upload).unwrap();
let config = Config {
server_url: server.uri(),
api_key: "test-key".into(),
oneliner_poll_interval_seconds: None,
oneliner_window_hours: None,
};
let client = reqwest::Client::new();
let (tx, mut rx) = mpsc::unbounded_channel();
upload_with_retry(&client, &config, upload.clone(), &tx).await;
assert!(!upload.file.exists(), "m4a should be deleted after ack");
assert!(
!sidecar_path_for(&upload.file).exists(),
"sidecar should be deleted after ack"
);
assert!(matches!(rx.recv().await, Some(UploadEvent::Started(_))));
let Some(UploadEvent::Succeeded { status, .. }) = rx.recv().await else {
panic!("expected Succeeded event");
};
assert_eq!(status, AckStatus::Received);
}
#[tokio::test(start_paused = true)]
async fn transient_500_retries_then_succeeds() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(wpath(UPLOAD_PATH))
.respond_with(ResponseTemplate::new(500))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(wpath(UPLOAD_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(ack_body(AckStatus::Received)))
.mount(&server)
.await;
let tmp = TempDir::new().unwrap();
let upload = sample_pending(tmp.path());
write_sidecar(&upload).unwrap();
let config = Config {
server_url: server.uri(),
api_key: "test-key".into(),
oneliner_poll_interval_seconds: None,
oneliner_window_hours: None,
};
let client = reqwest::Client::new();
let (tx, mut rx) = mpsc::unbounded_channel();
upload_with_retry(&client, &config, upload.clone(), &tx).await;
assert!(matches!(rx.recv().await, Some(UploadEvent::Started(_))));
let Some(UploadEvent::Failed { will_retry, .. }) = rx.recv().await else {
panic!("expected Failed event");
};
assert!(will_retry, "500 should be a transient failure");
assert!(matches!(rx.recv().await, Some(UploadEvent::Started(_))));
assert!(matches!(rx.recv().await, Some(UploadEvent::Succeeded { .. })));
}
#[tokio::test]
async fn terminal_401_keeps_files_and_does_not_retry() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(wpath(UPLOAD_PATH))
.respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
.mount(&server)
.await;
let tmp = TempDir::new().unwrap();
let upload = sample_pending(tmp.path());
write_sidecar(&upload).unwrap();
let config = Config {
server_url: server.uri(),
api_key: "bad-key".into(),
oneliner_poll_interval_seconds: None,
oneliner_window_hours: None,
};
let client = reqwest::Client::new();
let (tx, mut rx) = mpsc::unbounded_channel();
upload_with_retry(&client, &config, upload.clone(), &tx).await;
assert!(upload.file.exists(), "m4a must be preserved on terminal failure");
assert!(matches!(rx.recv().await, Some(UploadEvent::Started(_))));
let Some(UploadEvent::Failed { will_retry, .. }) = rx.recv().await else {
panic!("expected Failed event");
};
assert!(!will_retry, "401 is terminal; must not retry");
}
}