Files
doctate/server/src/lib.rs
T
Brummel f358da215d feat: lazy retention sweep on /web/cases
New module retention::sweep_user_retention is invoked at the top of
handle_my_cases, using the current user's retention policy. Per case:
step 1 auto-closes when the newest .m4a mtime is older than
auto_close_days; step 2 auto-purges when closed_at is older than
auto_delete_days. Order matters - a case auto-closed in the same sweep
has closed_at ~= now and naturally survives the purge check,
preserving the full grace period after a vacation.

Race guard: purge re-reads the close marker immediately before
remove_dir_all so a concurrent upload that reopens the case cancels
the purge. Every automatic action emits an info! line with reason and
threshold; failures warn but never abort the sweep.

Seven integration tests cover both threshold paths, the disable-via-0
escape hatch, the newest-recording rule, the vacation scenario, and
the open-case skip. Tests age the comparison objects (mtime via
filetime, closed_at as handwritten RFC3339) instead of faking "now" -
no clock abstraction needed.
2026-04-21 10:51:27 +02:00

183 lines
5.2 KiB
Rust

pub mod analyze;
pub mod auth;
pub mod case_id;
pub mod config;
pub mod error;
pub mod events;
pub mod gazetteer;
pub mod magic_link;
pub mod models;
pub mod paths;
pub mod retention;
pub mod routes;
pub mod transcribe;
pub mod web_session;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use axum::Router;
use axum::extract::FromRef;
use analyze::AnalyzeSender;
use config::Config;
use gazetteer::Gazetteer;
use magic_link::MagicLinkStore;
use transcribe::TranscribeSender;
use web_session::SessionStore;
/// Live "is this worker currently processing a job?" flag.
/// Set true before each job, false after. UI uses it to distinguish a real
/// in-flight job from a stale on-disk marker (orphan input, missing
/// transcript) left over from a crash.
pub type WorkerBusy = Arc<AtomicBool>;
/// Newtype wrappers so `FromRef<AppState>` can target the two busy-flags
/// separately even though both are `Arc<AtomicBool>` underneath.
#[derive(Clone)]
pub struct AnalyzeBusy(pub WorkerBusy);
#[derive(Clone)]
pub struct TranscribeBusy(pub WorkerBusy);
/// RAII guard that flips a [`WorkerBusy`] true on construction and false on
/// drop — panic-safe. Shared across analyze + transcribe workers.
pub struct BusyGuard(WorkerBusy);
impl BusyGuard {
pub fn new(busy: WorkerBusy) -> Self {
busy.store(true, Ordering::Release);
Self(busy)
}
}
impl Drop for BusyGuard {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
/// Bundle of the four worker-pipeline handles (analyze + transcribe,
/// each with a busy-flag and a job sender). Extracted so handlers that
/// need the whole pipeline (self-heal, status probes) can take a single
/// `State<PipelineState>` instead of four separate `State(...)` params.
/// The individual `FromRef<AppState>` impls below remain valid, so
/// handlers that only need one sender keep their narrower signature.
#[derive(Clone)]
pub struct PipelineState {
pub analyze_busy: AnalyzeBusy,
pub analyze_tx: AnalyzeSender,
pub transcribe_busy: TranscribeBusy,
pub transcribe_tx: TranscribeSender,
}
/// Shared application state. Clonable — all fields are cheap to clone
/// (`Arc` and `mpsc::Sender` / `broadcast::Sender`).
#[derive(Clone)]
pub struct AppState {
pub config: Arc<Config>,
pub transcribe_tx: TranscribeSender,
pub analyze_tx: AnalyzeSender,
pub session_store: SessionStore,
pub magic_link_store: MagicLinkStore,
pub analyze_busy: AnalyzeBusy,
pub transcribe_busy: TranscribeBusy,
pub events_tx: events::EventSender,
pub http_client: reqwest::Client,
pub vocab: Arc<Gazetteer>,
}
impl FromRef<AppState> for Arc<Config> {
fn from_ref(state: &AppState) -> Self {
state.config.clone()
}
}
impl FromRef<AppState> for TranscribeSender {
fn from_ref(state: &AppState) -> Self {
state.transcribe_tx.clone()
}
}
impl FromRef<AppState> for AnalyzeSender {
fn from_ref(state: &AppState) -> Self {
state.analyze_tx.clone()
}
}
impl FromRef<AppState> for SessionStore {
fn from_ref(state: &AppState) -> Self {
state.session_store.clone()
}
}
impl FromRef<AppState> for MagicLinkStore {
fn from_ref(state: &AppState) -> Self {
state.magic_link_store.clone()
}
}
impl FromRef<AppState> for AnalyzeBusy {
fn from_ref(state: &AppState) -> Self {
state.analyze_busy.clone()
}
}
impl FromRef<AppState> for TranscribeBusy {
fn from_ref(state: &AppState) -> Self {
state.transcribe_busy.clone()
}
}
impl FromRef<AppState> for PipelineState {
fn from_ref(state: &AppState) -> Self {
PipelineState {
analyze_busy: state.analyze_busy.clone(),
analyze_tx: state.analyze_tx.clone(),
transcribe_busy: state.transcribe_busy.clone(),
transcribe_tx: state.transcribe_tx.clone(),
}
}
}
impl FromRef<AppState> for events::EventSender {
fn from_ref(state: &AppState) -> Self {
state.events_tx.clone()
}
}
impl FromRef<AppState> for reqwest::Client {
fn from_ref(state: &AppState) -> Self {
state.http_client.clone()
}
}
impl FromRef<AppState> for Arc<Gazetteer> {
fn from_ref(state: &AppState) -> Self {
state.vocab.clone()
}
}
/// Test/simple entrypoint: jobs pushed into either channel are dropped
/// because the receivers are not retained. Use [`create_router_with_state`]
/// from `main.rs` where real workers own the receivers.
pub fn create_router(config: Arc<Config>) -> Router {
let (transcribe_tx, _transcribe_rx) = transcribe::channel();
let (analyze_tx, _analyze_rx) = analyze::channel();
create_router_with_state(AppState {
config,
transcribe_tx,
analyze_tx,
session_store: web_session::new_store(),
magic_link_store: magic_link::new_store(),
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
events_tx: events::channel(),
http_client: reqwest::Client::new(),
vocab: Arc::new(Gazetteer::empty()),
})
}
pub fn create_router_with_state(state: AppState) -> Router {
routes::api_router().with_state(state)
}