Files
doctate/server/src/lib.rs
T
Brummel 76bf65be1a Refactor: Track analysis jobs via InFlight struct
This commit replaces the disk-based `analysis_input.json` marker for
in-progress analysis jobs with an in-memory
`Arc<RwLock<HashSet<PathBuf>>>`.

Previously, the existence of `analysis_input.json` was used as a signal
that a case was queued or being analyzed. This led to stale "Queued"
states after server restarts, as the disk file would persist while the
server process tracking it had vanished.

The new `InFlight` struct, held in `AppState`, provides a process-local
marker. This ensures that:

- Server restarts correctly reset the state, as the `HashSet` is
  cleared.
- Race conditions for triggering analysis are handled by the `try_claim`
  method, replacing the previous reliance on file system `O_EXCL` flags.
- The `AnalyzeJob` payload now travels with the job over the channel,
  eliminating the need for workers to re-read `analysis_input.json` from
  disk.

This change simplifies state management and improves the reliability of
analysis job tracking.
2026-05-05 20:01:07 +02:00

350 lines
12 KiB
Rust

pub mod analyze;
pub mod auth;
pub mod case_id;
pub mod config;
pub mod csrf;
pub mod error;
pub mod events;
pub mod gazetteer;
pub mod loudness;
pub mod magic_link;
pub mod models;
pub mod oneliner_locks;
pub mod paths;
pub mod retention;
pub mod routes;
pub mod settings;
pub mod transcribe;
pub mod validate;
pub mod web_session;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use axum::Router;
use axum::extract::FromRef;
use axum::http::{HeaderName, HeaderValue};
use tower_http::set_header::SetResponseHeaderLayer;
use analyze::{AnalyzeSender, InFlight};
use config::Config;
use gazetteer::Gazetteer;
use magic_link::MagicLinkStore;
use oneliner_locks::OnelinerLocks;
use settings::Settings;
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);
/// Dedup flag for the detached oneliner-heal `tokio::spawn` fired from
/// `heal_orphans_if_idle`. The handler CAS's this to `true`; the spawned
/// task resets via [`BusyGuard`] on drop. Keeps concurrent page-loads
/// from launching parallel Ollama-call loops against the same data.
#[derive(Clone)]
pub struct OnelinerHealBusy(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,
pub oneliner_heal_busy: OnelinerHealBusy,
pub oneliner_locks: OnelinerLocks,
}
/// Newtype for the server's start timestamp. Functions as the lower
/// bound for the analyze idle-trigger: a case is only auto-enqueued
/// once `now - max(latest_recording_mtime, boot_time) >= threshold`.
/// Without this, a server restart would treat every case with an
/// older recording as immediately `Idle` and spawn a thundering herd
/// of LLM calls on the first `/cases` render.
#[derive(Clone, Copy, Debug)]
pub struct BootTime(pub std::time::SystemTime);
/// 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 settings: Arc<Settings>,
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 oneliner_heal_busy: OnelinerHealBusy,
pub oneliner_locks: OnelinerLocks,
pub events_tx: events::EventSender,
pub http_client: reqwest::Client,
pub vocab: Arc<Gazetteer>,
pub boot_time: BootTime,
/// Process-local set of cases currently queued or being analyzed.
/// Replaces the old "does `analysis_input.json` exist on disk?"
/// marker so a server restart never reports stale `Queued` states.
pub analyze_in_flight: InFlight,
}
impl FromRef<AppState> for Arc<Config> {
fn from_ref(state: &AppState) -> Self {
state.config.clone()
}
}
impl FromRef<AppState> for Arc<Settings> {
fn from_ref(state: &AppState) -> Self {
state.settings.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 OnelinerHealBusy {
fn from_ref(state: &AppState) -> Self {
state.oneliner_heal_busy.clone()
}
}
impl FromRef<AppState> for OnelinerLocks {
fn from_ref(state: &AppState) -> Self {
state.oneliner_locks.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(),
oneliner_heal_busy: state.oneliner_heal_busy.clone(),
oneliner_locks: state.oneliner_locks.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()
}
}
impl FromRef<AppState> for BootTime {
fn from_ref(state: &AppState) -> Self {
state.boot_time
}
}
impl FromRef<AppState> for InFlight {
fn from_ref(state: &AppState) -> Self {
state.analyze_in_flight.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 {
create_router_and_session_store(config).0
}
/// Same as [`create_router`], but also returns a handle to the newly
/// created session store so tests can peek at `WebSession::csrf_token`
/// values without rendering and parsing HTML. The store handle is
/// shared with the router (`Arc`), so any session created via `/login`
/// or `/magic` becomes observable through it.
pub fn create_router_and_session_store(config: Arc<Config>) -> (Router, SessionStore) {
create_router_and_session_store_with_settings(config, Arc::new(Settings::default()))
}
/// Variant of [`create_router`] that takes both `Config` and `Settings`.
/// Use from tests that exercise Bucket-B paths (LLM, Whisper, Ollama URLs);
/// otherwise the [`create_router`] convenience with default Settings is enough.
pub fn create_router_with_settings(config: Arc<Config>, settings: Arc<Settings>) -> Router {
create_router_and_session_store_with_settings(config, settings).0
}
/// Variant of [`create_router_and_session_store`] that takes both `Config` and `Settings`.
pub fn create_router_and_session_store_with_settings(
config: Arc<Config>,
settings: Arc<Settings>,
) -> (Router, SessionStore) {
let (router, session_store, _, _) = create_router_with_full_handles(config, settings);
(router, session_store)
}
/// Test-friendly variant: returns the router, session store, the
/// process-local [`InFlight`] set, and the receiver half of the
/// analyze channel. Tests that need to inspect the [`AnalyzeJob`]
/// payload (recordings, backend_id) consume the receiver to read the
/// job a handler just sent. Tests that only care about the HTTP
/// response keep using [`create_router_and_session_store_with_settings`].
pub fn create_router_with_full_handles(
config: Arc<Config>,
settings: Arc<Settings>,
) -> (
Router,
SessionStore,
analyze::InFlight,
analyze::AnalyzeReceiver,
) {
let (transcribe_tx, _transcribe_rx) = transcribe::channel();
let (analyze_tx, analyze_rx) = analyze::channel();
let session_store = web_session::new_store();
let in_flight = InFlight::new();
let router = create_router_with_state(AppState {
config,
settings,
transcribe_tx,
analyze_tx,
session_store: session_store.clone(),
magic_link_store: magic_link::new_store(),
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))),
oneliner_locks: OnelinerLocks::new(),
events_tx: events::channel(),
http_client: reqwest::Client::new(),
vocab: Arc::new(Gazetteer::empty()),
// UNIX_EPOCH = "the server has always been up" — disables the
// post-boot grace window in tests, which is what most tests want
// anyway. Tests that exercise the grace-window itself construct
// an `AppState` with an explicit recent `BootTime`.
boot_time: BootTime(std::time::SystemTime::UNIX_EPOCH),
analyze_in_flight: in_flight.clone(),
});
(router, session_store, in_flight, analyze_rx)
}
pub fn create_router_with_state(state: AppState) -> Router {
with_security_headers(routes::api_router().with_state(state))
}
/// Content-Security-Policy for all responses. `'unsafe-inline'` on
/// script-src / style-src is a pragmatic concession — the templates
/// include inline scripts (e.g. partials/time_format.js, localStorage
/// toggles) and user content is always escaped by askama before
/// rendering, so the XSS surface is small. Revisit if ever rendering
/// unescaped user input.
const CSP: &str = "default-src 'self'; \
script-src 'self' 'unsafe-inline'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data:; \
media-src 'self'; \
object-src 'none'; \
base-uri 'self'; \
frame-ancestors 'none'; \
form-action 'self'";
const PERMISSIONS_POLICY: &str = "microphone=(), camera=(), geolocation=(), payment=()";
/// Defense-in-depth security headers applied to every response.
/// `if_not_present` respects per-route overrides (see routes/magic.rs,
/// which sets its own Referrer-Policy). HSTS is deliberately omitted
/// until TLS termination is in place; adding it prematurely on a
/// plain-HTTP deployment is irreversible (browser caches max-age).
fn with_security_headers(router: Router) -> Router {
router
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("no-referrer"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("content-security-policy"),
HeaderValue::from_static(CSP),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("permissions-policy"),
HeaderValue::from_static(PERMISSIONS_POLICY),
))
}