Introduce boot_time to evaluate_state
The `boot_time` parameter is introduced to `evaluate_state` and its callers. This parameter acts as a lower bound for the "seen at" timestamp used in determining the `Idle` state for cases. Previously, the `elapsed` duration was calculated as `now.duration_since(latest_mtime)`. This meant that a server restart could immediately cause old recordings to be classified as `Idle` if their modification times were significantly in the past, potentially triggering a large number of LLM calls upon the first render of the `/cases` page. By using `seen_at = std::cmp::max(latest_mtime, boot_time)`, the idle calculation now ensures that a fresh server start respects a grace period. Old recordings will not satisfy the idle threshold until the elapsed time since the server's boot (or the recording's modification time, whichever is later) exceeds the `idle_threshold`. `SystemTime::UNIX_EPOCH` is used as the default `boot_time` in tests and in `create_router_and_session_store_with_settings`. This preserves existing test behavior where the idle grace period is effectively disabled. For production, `main.rs` now captures the server's actual boot time and passes it down. This change also refactors `evaluate_case` and `try_enqueue` to accept the new `boot_time` parameter. `try_enqueue_all_for_user` is updated to pass `boot_time` along. `boot_scan_state` and `compute_state_for_user` in `recovery.rs` also receive `boot_time`. Additionally, a new test case `analyze_required_endpoint_enqueues_only_required_cases` is added to verify the behavior of the `/cases/analyze-required` endpoint. This endpoint now correctly enqueues only `Required`
This commit is contained in:
@@ -166,10 +166,17 @@ pub enum CaseAnalysisState {
|
|||||||
///
|
///
|
||||||
/// `now` is injected so tests can pin filesystem mtimes against a known
|
/// `now` is injected so tests can pin filesystem mtimes against a known
|
||||||
/// reference point. Production callers pass `SystemTime::now()`.
|
/// reference point. Production callers pass `SystemTime::now()`.
|
||||||
|
///
|
||||||
|
/// `boot_time` is the lower bound for the "seen at"-clock: idle is
|
||||||
|
/// measured against `max(latest_recording_mtime, boot_time)`, so a
|
||||||
|
/// fresh server start does not instantly classify every old case as
|
||||||
|
/// `Idle` (and trigger a thundering herd of LLM calls on the first
|
||||||
|
/// `/cases` render). Pass `SystemTime::UNIX_EPOCH` to disable.
|
||||||
pub async fn evaluate_state(
|
pub async fn evaluate_state(
|
||||||
case_dir: &Path,
|
case_dir: &Path,
|
||||||
idle_threshold: std::time::Duration,
|
idle_threshold: std::time::Duration,
|
||||||
now: SystemTime,
|
now: SystemTime,
|
||||||
|
boot_time: SystemTime,
|
||||||
) -> CaseAnalysisState {
|
) -> CaseAnalysisState {
|
||||||
let scan = scan_m4as(case_dir).await;
|
let scan = scan_m4as(case_dir).await;
|
||||||
|
|
||||||
@@ -213,9 +220,13 @@ pub async fn evaluate_state(
|
|||||||
|
|
||||||
// Stale document (or no document at all): differentiate between
|
// Stale document (or no document at all): differentiate between
|
||||||
// "user might still be dictating" (Required) and "long enough quiet,
|
// "user might still be dictating" (Required) and "long enough quiet,
|
||||||
// fire" (Idle).
|
// fire" (Idle). The "seen at" clock is bounded below by the server
|
||||||
|
// boot time so a restart re-arms the grace window — old recordings
|
||||||
|
// do not instantly satisfy the threshold just because their mtime
|
||||||
|
// happens to be ancient.
|
||||||
|
let seen_at = std::cmp::max(latest_mtime, boot_time);
|
||||||
let elapsed = now
|
let elapsed = now
|
||||||
.duration_since(latest_mtime)
|
.duration_since(seen_at)
|
||||||
.unwrap_or(std::time::Duration::ZERO);
|
.unwrap_or(std::time::Duration::ZERO);
|
||||||
if elapsed >= idle_threshold {
|
if elapsed >= idle_threshold {
|
||||||
CaseAnalysisState::Idle { has_document }
|
CaseAnalysisState::Idle { has_document }
|
||||||
@@ -242,7 +253,13 @@ pub(crate) async fn read_document_meta(case_dir: &Path) -> Option<crate::analyze
|
|||||||
/// `Duration::ZERO` as the threshold, `Required` collapses into `Idle`
|
/// `Duration::ZERO` as the threshold, `Required` collapses into `Idle`
|
||||||
/// in practice, so the eager pre-refactor semantics are preserved.
|
/// in practice, so the eager pre-refactor semantics are preserved.
|
||||||
pub async fn evaluate_case(case_dir: &Path) -> AutoDecision {
|
pub async fn evaluate_case(case_dir: &Path) -> AutoDecision {
|
||||||
let state = evaluate_state(case_dir, std::time::Duration::ZERO, SystemTime::now()).await;
|
let state = evaluate_state(
|
||||||
|
case_dir,
|
||||||
|
std::time::Duration::ZERO,
|
||||||
|
SystemTime::now(),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
match state {
|
match state {
|
||||||
CaseAnalysisState::NoRecordings => AutoDecision::Skip("no recordings"),
|
CaseAnalysisState::NoRecordings => AutoDecision::Skip("no recordings"),
|
||||||
CaseAnalysisState::Pending => AutoDecision::Skip("not all transcribed"),
|
CaseAnalysisState::Pending => AutoDecision::Skip("not all transcribed"),
|
||||||
@@ -272,10 +289,11 @@ pub async fn try_enqueue(
|
|||||||
case_dir: &Path,
|
case_dir: &Path,
|
||||||
idle_threshold: std::time::Duration,
|
idle_threshold: std::time::Duration,
|
||||||
now: SystemTime,
|
now: SystemTime,
|
||||||
|
boot_time: SystemTime,
|
||||||
tx: &AnalyzeSender,
|
tx: &AnalyzeSender,
|
||||||
events_tx: &EventSender,
|
events_tx: &EventSender,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
match evaluate_state(case_dir, idle_threshold, now).await {
|
match evaluate_state(case_dir, idle_threshold, now, boot_time).await {
|
||||||
CaseAnalysisState::Idle { .. } => {}
|
CaseAnalysisState::Idle { .. } => {}
|
||||||
_ => return false,
|
_ => return false,
|
||||||
}
|
}
|
||||||
@@ -324,6 +342,7 @@ pub async fn try_enqueue_all_for_user(
|
|||||||
user_root: &Path,
|
user_root: &Path,
|
||||||
idle_threshold: std::time::Duration,
|
idle_threshold: std::time::Duration,
|
||||||
now: SystemTime,
|
now: SystemTime,
|
||||||
|
boot_time: SystemTime,
|
||||||
tx: &AnalyzeSender,
|
tx: &AnalyzeSender,
|
||||||
events_tx: &EventSender,
|
events_tx: &EventSender,
|
||||||
) {
|
) {
|
||||||
@@ -344,7 +363,7 @@ pub async fn try_enqueue_all_for_user(
|
|||||||
if crate::paths::is_closed(&case_path).await {
|
if crate::paths::is_closed(&case_path).await {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try_enqueue(&case_path, idle_threshold, now, tx, events_tx).await;
|
try_enqueue(&case_path, idle_threshold, now, boot_time, tx, events_tx).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,7 +515,10 @@ pub(crate) async fn read_failure_marker(case_dir: &Path) -> Option<FailureMarker
|
|||||||
/// Auto-trigger re-analyses may legitimately replace an existing input
|
/// Auto-trigger re-analyses may legitimately replace an existing input
|
||||||
/// (e.g. a new recording arrived between two reloads while the previous
|
/// (e.g. a new recording arrived between two reloads while the previous
|
||||||
/// job was still queued). `.tmp` + fsync + rename keeps it atomic.
|
/// job was still queued). `.tmp` + fsync + rename keeps it atomic.
|
||||||
pub(crate) async fn write_input_overwrite(path: &Path, input: &AnalysisInput) -> std::io::Result<()> {
|
pub(crate) async fn write_input_overwrite(
|
||||||
|
path: &Path,
|
||||||
|
input: &AnalysisInput,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
let bytes = serde_json::to_vec_pretty(input)
|
let bytes = serde_json::to_vec_pretty(input)
|
||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
let tmp = path.with_extension("json.tmp");
|
let tmp = path.with_extension("json.tmp");
|
||||||
@@ -756,6 +778,154 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn idle_threshold_not_reached_returns_required() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let m4a = dir.path().join("2026-01-01T10-00-00Z.m4a");
|
||||||
|
touch(&m4a).await;
|
||||||
|
seed_meta(dir.path(), "2026-01-01T10-00-00Z").await;
|
||||||
|
|
||||||
|
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
|
||||||
|
set_mtime(&m4a, base);
|
||||||
|
|
||||||
|
// `now` is 100s after the recording, threshold is 900s → still
|
||||||
|
// inside the quiet window, so the case is `Required`, not `Idle`.
|
||||||
|
let state = evaluate_state(
|
||||||
|
dir.path(),
|
||||||
|
Duration::from_secs(900),
|
||||||
|
base + Duration::from_secs(100),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state,
|
||||||
|
CaseAnalysisState::Required {
|
||||||
|
has_document: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn idle_threshold_reached_returns_idle() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let m4a = dir.path().join("2026-01-01T10-00-00Z.m4a");
|
||||||
|
touch(&m4a).await;
|
||||||
|
seed_meta(dir.path(), "2026-01-01T10-00-00Z").await;
|
||||||
|
|
||||||
|
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
|
||||||
|
set_mtime(&m4a, base);
|
||||||
|
|
||||||
|
// `now` is 1000s after the recording, threshold is 900s → quiet
|
||||||
|
// window has elapsed, the auto-trigger may fire.
|
||||||
|
let state = evaluate_state(
|
||||||
|
dir.path(),
|
||||||
|
Duration::from_secs(900),
|
||||||
|
base + Duration::from_secs(1000),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state,
|
||||||
|
CaseAnalysisState::Idle {
|
||||||
|
has_document: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh server boot must NOT instantly classify ancient
|
||||||
|
/// recordings as `Idle`. Even when `now - latest_mtime` exceeds the
|
||||||
|
/// threshold by orders of magnitude, the boot-time lower bound
|
||||||
|
/// pulls the "seen at"-clock forward and the case stays `Required`
|
||||||
|
/// until the post-boot grace window has elapsed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn boot_time_reanchors_idle_window_after_restart() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let m4a = dir.path().join("2026-01-01T10-00-00Z.m4a");
|
||||||
|
touch(&m4a).await;
|
||||||
|
seed_meta(dir.path(), "2026-01-01T10-00-00Z").await;
|
||||||
|
|
||||||
|
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
|
||||||
|
set_mtime(&m4a, base);
|
||||||
|
|
||||||
|
// The recording is 30 days old in wallclock terms — but the
|
||||||
|
// server only booted 100 s ago. With a 900 s threshold that
|
||||||
|
// means the grace window has not yet elapsed: state must be
|
||||||
|
// Required, not Idle.
|
||||||
|
let now = base + Duration::from_secs(30 * 24 * 3600);
|
||||||
|
let boot = now - Duration::from_secs(100);
|
||||||
|
let state = evaluate_state(dir.path(), Duration::from_secs(900), now, boot).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state,
|
||||||
|
CaseAnalysisState::Required {
|
||||||
|
has_document: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Once the grace window has elapsed (boot was 1000 s ago) the
|
||||||
|
// case promotes to Idle.
|
||||||
|
let boot_old = now - Duration::from_secs(1000);
|
||||||
|
let state_old = evaluate_state(dir.path(), Duration::from_secs(900), now, boot_old).await;
|
||||||
|
assert_eq!(
|
||||||
|
state_old,
|
||||||
|
CaseAnalysisState::Idle {
|
||||||
|
has_document: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn try_enqueue_skips_required_enqueues_idle() {
|
||||||
|
use crate::analyze::channel as analyze_channel;
|
||||||
|
use crate::events::channel as events_channel;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let m4a = dir.path().join("2026-01-01T10-00-00Z.m4a");
|
||||||
|
touch(&m4a).await;
|
||||||
|
seed_meta_with(dir.path(), "2026-01-01T10-00-00Z", "patient mit fieber").await;
|
||||||
|
|
||||||
|
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
|
||||||
|
set_mtime(&m4a, base);
|
||||||
|
|
||||||
|
let (tx, mut rx) = analyze_channel();
|
||||||
|
let events_tx = events_channel();
|
||||||
|
|
||||||
|
// Idle threshold not reached → must NOT enqueue.
|
||||||
|
let sent_required = try_enqueue(
|
||||||
|
dir.path(),
|
||||||
|
Duration::from_secs(900),
|
||||||
|
base + Duration::from_secs(100),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
|
&tx,
|
||||||
|
&events_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(!sent_required, "Required state must not enqueue");
|
||||||
|
assert!(
|
||||||
|
rx.try_recv().is_err(),
|
||||||
|
"channel must be empty after Required"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same case, idle threshold passed → must enqueue exactly one job.
|
||||||
|
let sent_idle = try_enqueue(
|
||||||
|
dir.path(),
|
||||||
|
Duration::from_secs(900),
|
||||||
|
base + Duration::from_secs(1000),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
|
&tx,
|
||||||
|
&events_tx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(sent_idle, "Idle state must enqueue");
|
||||||
|
assert!(
|
||||||
|
rx.try_recv().is_ok(),
|
||||||
|
"channel must hold one job after Idle"
|
||||||
|
);
|
||||||
|
assert!(rx.try_recv().is_err(), "channel must hold exactly one job");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn try_enqueue_all_sends_only_eligible_cases() {
|
async fn try_enqueue_all_sends_only_eligible_cases() {
|
||||||
use crate::analyze::channel as analyze_channel;
|
use crate::analyze::channel as analyze_channel;
|
||||||
@@ -803,6 +973,7 @@ mod tests {
|
|||||||
user_root.path(),
|
user_root.path(),
|
||||||
std::time::Duration::ZERO,
|
std::time::Duration::ZERO,
|
||||||
SystemTime::now(),
|
SystemTime::now(),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
&tx,
|
&tx,
|
||||||
&events_tx,
|
&events_tx,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,17 +55,23 @@ impl BootStateSummary {
|
|||||||
|
|
||||||
/// Walk `data_path/*/` and emit a single info-level summary line. The
|
/// Walk `data_path/*/` and emit a single info-level summary line. The
|
||||||
/// returned summary is mainly for tests; production callers ignore it.
|
/// returned summary is mainly for tests; production callers ignore it.
|
||||||
|
///
|
||||||
|
/// `boot_time` is forwarded to [`evaluate_state`]. Boot-scan typically
|
||||||
|
/// passes `SystemTime::now()` here — the same instant it itself observes
|
||||||
|
/// the case directories — so old recordings count as "just seen now".
|
||||||
pub async fn boot_scan_state(
|
pub async fn boot_scan_state(
|
||||||
data_path: &Path,
|
data_path: &Path,
|
||||||
idle_threshold: Duration,
|
idle_threshold: Duration,
|
||||||
now: SystemTime,
|
now: SystemTime,
|
||||||
|
boot_time: SystemTime,
|
||||||
) -> BootStateSummary {
|
) -> BootStateSummary {
|
||||||
let mut total = BootStateSummary::default();
|
let mut total = BootStateSummary::default();
|
||||||
let Ok(mut users) = tokio::fs::read_dir(data_path).await else {
|
let Ok(mut users) = tokio::fs::read_dir(data_path).await else {
|
||||||
return total;
|
return total;
|
||||||
};
|
};
|
||||||
while let Ok(Some(user_entry)) = users.next_entry().await {
|
while let Ok(Some(user_entry)) = users.next_entry().await {
|
||||||
let per_user = compute_state_for_user(&user_entry.path(), idle_threshold, now).await;
|
let per_user =
|
||||||
|
compute_state_for_user(&user_entry.path(), idle_threshold, now, boot_time).await;
|
||||||
total.add(per_user);
|
total.add(per_user);
|
||||||
}
|
}
|
||||||
info!(
|
info!(
|
||||||
@@ -85,6 +91,7 @@ pub async fn compute_state_for_user(
|
|||||||
user_root: &Path,
|
user_root: &Path,
|
||||||
idle_threshold: Duration,
|
idle_threshold: Duration,
|
||||||
now: SystemTime,
|
now: SystemTime,
|
||||||
|
boot_time: SystemTime,
|
||||||
) -> BootStateSummary {
|
) -> BootStateSummary {
|
||||||
let mut summary = BootStateSummary::default();
|
let mut summary = BootStateSummary::default();
|
||||||
let Ok(mut cases) = tokio::fs::read_dir(user_root).await else {
|
let Ok(mut cases) = tokio::fs::read_dir(user_root).await else {
|
||||||
@@ -101,7 +108,7 @@ pub async fn compute_state_for_user(
|
|||||||
if uuid::Uuid::parse_str(&name).is_err() {
|
if uuid::Uuid::parse_str(&name).is_err() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let state = evaluate_state(&case_dir, idle_threshold, now).await;
|
let state = evaluate_state(&case_dir, idle_threshold, now, boot_time).await;
|
||||||
summary.record(&state);
|
summary.record(&state);
|
||||||
}
|
}
|
||||||
summary
|
summary
|
||||||
@@ -129,7 +136,11 @@ mod tests {
|
|||||||
|
|
||||||
/// Seed a case with a single transcribed recording at `mtime`.
|
/// Seed a case with a single transcribed recording at `mtime`.
|
||||||
/// Returns the case_dir for further fixture operations.
|
/// Returns the case_dir for further fixture operations.
|
||||||
async fn seed_transcribed_case(user_root: &Path, n: u8, mtime: SystemTime) -> std::path::PathBuf {
|
async fn seed_transcribed_case(
|
||||||
|
user_root: &Path,
|
||||||
|
n: u8,
|
||||||
|
mtime: SystemTime,
|
||||||
|
) -> std::path::PathBuf {
|
||||||
let case = user_root.join(case_id(n));
|
let case = user_root.join(case_id(n));
|
||||||
fs::create_dir_all(&case).await.unwrap();
|
fs::create_dir_all(&case).await.unwrap();
|
||||||
let stem = "2026-01-01T10-00-00Z";
|
let stem = "2026-01-01T10-00-00Z";
|
||||||
@@ -203,8 +214,13 @@ mod tests {
|
|||||||
let case = seed_transcribed_case(user_root.path(), 1, base_time()).await;
|
let case = seed_transcribed_case(user_root.path(), 1, base_time()).await;
|
||||||
write_input(&case, base_time()).await;
|
write_input(&case, base_time()).await;
|
||||||
|
|
||||||
let summary =
|
let summary = compute_state_for_user(
|
||||||
compute_state_for_user(user_root.path(), Duration::ZERO, base_time()).await;
|
user_root.path(),
|
||||||
|
Duration::ZERO,
|
||||||
|
base_time(),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(summary.queued, 1);
|
assert_eq!(summary.queued, 1);
|
||||||
assert_eq!(summary.idle, 0);
|
assert_eq!(summary.idle, 0);
|
||||||
@@ -217,8 +233,13 @@ mod tests {
|
|||||||
let case = seed_transcribed_case(user_root.path(), 2, base_time()).await;
|
let case = seed_transcribed_case(user_root.path(), 2, base_time()).await;
|
||||||
write_document_covering(&case, base_time()).await;
|
write_document_covering(&case, base_time()).await;
|
||||||
|
|
||||||
let summary =
|
let summary = compute_state_for_user(
|
||||||
compute_state_for_user(user_root.path(), Duration::ZERO, base_time()).await;
|
user_root.path(),
|
||||||
|
Duration::ZERO,
|
||||||
|
base_time(),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(summary.current, 1);
|
assert_eq!(summary.current, 1);
|
||||||
assert_eq!(summary.queued, 0);
|
assert_eq!(summary.queued, 0);
|
||||||
@@ -232,8 +253,13 @@ mod tests {
|
|||||||
let case = seed_transcribed_case(user_root.path(), 3, base_time()).await;
|
let case = seed_transcribed_case(user_root.path(), 3, base_time()).await;
|
||||||
write_marker(&case, base_time()).await;
|
write_marker(&case, base_time()).await;
|
||||||
|
|
||||||
let summary =
|
let summary = compute_state_for_user(
|
||||||
compute_state_for_user(user_root.path(), Duration::ZERO, base_time()).await;
|
user_root.path(),
|
||||||
|
Duration::ZERO,
|
||||||
|
base_time(),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(summary.failed, 1);
|
assert_eq!(summary.failed, 1);
|
||||||
}
|
}
|
||||||
@@ -246,8 +272,13 @@ mod tests {
|
|||||||
let case = seed_transcribed_case(user_root.path(), 4, base_time()).await;
|
let case = seed_transcribed_case(user_root.path(), 4, base_time()).await;
|
||||||
write_marker(&case, base_time() - Duration::from_secs(3600)).await;
|
write_marker(&case, base_time() - Duration::from_secs(3600)).await;
|
||||||
|
|
||||||
let summary =
|
let summary = compute_state_for_user(
|
||||||
compute_state_for_user(user_root.path(), Duration::ZERO, base_time()).await;
|
user_root.path(),
|
||||||
|
Duration::ZERO,
|
||||||
|
base_time(),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(summary.idle, 1);
|
assert_eq!(summary.idle, 1);
|
||||||
assert_eq!(summary.failed, 0);
|
assert_eq!(summary.failed, 0);
|
||||||
@@ -262,8 +293,13 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let summary =
|
let summary = compute_state_for_user(
|
||||||
compute_state_for_user(user_root.path(), Duration::ZERO, base_time()).await;
|
user_root.path(),
|
||||||
|
Duration::ZERO,
|
||||||
|
base_time(),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(summary, BootStateSummary::default());
|
assert_eq!(summary, BootStateSummary::default());
|
||||||
}
|
}
|
||||||
@@ -279,6 +315,7 @@ mod tests {
|
|||||||
user_root.path(),
|
user_root.path(),
|
||||||
Duration::from_secs(900),
|
Duration::from_secs(900),
|
||||||
base_time() + Duration::from_secs(100),
|
base_time() + Duration::from_secs(100),
|
||||||
|
SystemTime::UNIX_EPOCH,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ pub struct PipelineState {
|
|||||||
pub oneliner_locks: OnelinerLocks,
|
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
|
/// Shared application state. Clonable — all fields are cheap to clone
|
||||||
/// (`Arc` and `mpsc::Sender` / `broadcast::Sender`).
|
/// (`Arc` and `mpsc::Sender` / `broadcast::Sender`).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -104,6 +113,7 @@ pub struct AppState {
|
|||||||
pub events_tx: events::EventSender,
|
pub events_tx: events::EventSender,
|
||||||
pub http_client: reqwest::Client,
|
pub http_client: reqwest::Client,
|
||||||
pub vocab: Arc<Gazetteer>,
|
pub vocab: Arc<Gazetteer>,
|
||||||
|
pub boot_time: BootTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRef<AppState> for Arc<Config> {
|
impl FromRef<AppState> for Arc<Config> {
|
||||||
@@ -197,6 +207,12 @@ impl FromRef<AppState> for Arc<Gazetteer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FromRef<AppState> for BootTime {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.boot_time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Test/simple entrypoint: jobs pushed into either channel are dropped
|
/// Test/simple entrypoint: jobs pushed into either channel are dropped
|
||||||
/// because the receivers are not retained. Use [`create_router_with_state`]
|
/// because the receivers are not retained. Use [`create_router_with_state`]
|
||||||
/// from `main.rs` where real workers own the receivers.
|
/// from `main.rs` where real workers own the receivers.
|
||||||
@@ -242,6 +258,11 @@ pub fn create_router_and_session_store_with_settings(
|
|||||||
events_tx: events::channel(),
|
events_tx: events::channel(),
|
||||||
http_client: reqwest::Client::new(),
|
http_client: reqwest::Client::new(),
|
||||||
vocab: Arc::new(Gazetteer::empty()),
|
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),
|
||||||
});
|
});
|
||||||
(router, session_store)
|
(router, session_store)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -197,15 +197,16 @@ async fn main() {
|
|||||||
vocab.clone(),
|
vocab.clone(),
|
||||||
events_tx.clone(),
|
events_tx.clone(),
|
||||||
));
|
));
|
||||||
|
let server_boot_time = std::time::SystemTime::now();
|
||||||
{
|
{
|
||||||
let data_path = config.data_path.clone();
|
let data_path = config.data_path.clone();
|
||||||
let idle_threshold =
|
let idle_threshold = std::time::Duration::from_secs(config.analysis_idle_threshold_secs);
|
||||||
std::time::Duration::from_secs(config.analysis_idle_threshold_secs);
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
analyze::recovery::boot_scan_state(
|
analyze::recovery::boot_scan_state(
|
||||||
&data_path,
|
&data_path,
|
||||||
idle_threshold,
|
idle_threshold,
|
||||||
std::time::SystemTime::now(),
|
std::time::SystemTime::now(),
|
||||||
|
server_boot_time,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
@@ -225,6 +226,7 @@ async fn main() {
|
|||||||
events_tx,
|
events_tx,
|
||||||
http_client,
|
http_client,
|
||||||
vocab,
|
vocab,
|
||||||
|
boot_time: doctate_server::BootTime(server_boot_time),
|
||||||
};
|
};
|
||||||
|
|
||||||
let addr = format!("0.0.0.0:{}", config.server_port);
|
let addr = format!("0.0.0.0:{}", config.server_port);
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ use doctate_common::timestamp::now_rfc3339;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::analyze::auto_trigger::{
|
use crate::analyze::auto_trigger::{self, CaseAnalysisState, write_input_overwrite};
|
||||||
self, CaseAnalysisState, write_input_overwrite,
|
|
||||||
};
|
|
||||||
use crate::analyze::backend::any_backend_available;
|
use crate::analyze::backend::any_backend_available;
|
||||||
use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender};
|
use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender};
|
||||||
use crate::auth::AuthenticatedWebUser;
|
use crate::auth::AuthenticatedWebUser;
|
||||||
@@ -237,6 +235,7 @@ pub async fn handle_analyze_required(
|
|||||||
State(config): State<Arc<Config>>,
|
State(config): State<Arc<Config>>,
|
||||||
State(analyze_tx): State<AnalyzeSender>,
|
State(analyze_tx): State<AnalyzeSender>,
|
||||||
State(events_tx): State<EventSender>,
|
State(events_tx): State<EventSender>,
|
||||||
|
State(boot_time): State<crate::BootTime>,
|
||||||
CsrfForm(_form): CsrfForm<AnalyzeRequiredForm>,
|
CsrfForm(_form): CsrfForm<AnalyzeRequiredForm>,
|
||||||
) -> Result<Redirect, AppError> {
|
) -> Result<Redirect, AppError> {
|
||||||
let user_root = config.data_path.join(&user.slug);
|
let user_root = config.data_path.join(&user.slug);
|
||||||
@@ -247,8 +246,7 @@ pub async fn handle_analyze_required(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = std::time::SystemTime::now();
|
let now = std::time::SystemTime::now();
|
||||||
let idle_threshold =
|
let idle_threshold = std::time::Duration::from_secs(config.analysis_idle_threshold_secs);
|
||||||
std::time::Duration::from_secs(config.analysis_idle_threshold_secs);
|
|
||||||
|
|
||||||
let mut ok = 0usize;
|
let mut ok = 0usize;
|
||||||
let mut skipped = 0usize;
|
let mut skipped = 0usize;
|
||||||
@@ -278,7 +276,7 @@ pub async fn handle_analyze_required(
|
|||||||
// are intentionally left alone (Failed → manual per-backend retry
|
// are intentionally left alone (Failed → manual per-backend retry
|
||||||
// path on the case page; Queued → already in flight; Current →
|
// path on the case page; Queued → already in flight; Current →
|
||||||
// nothing to do).
|
// nothing to do).
|
||||||
match auto_trigger::evaluate_state(&case_dir, idle_threshold, now).await {
|
match auto_trigger::evaluate_state(&case_dir, idle_threshold, now, boot_time.0).await {
|
||||||
CaseAnalysisState::Required { .. } | CaseAnalysisState::Idle { .. } => {}
|
CaseAnalysisState::Required { .. } | CaseAnalysisState::Idle { .. } => {}
|
||||||
_ => {
|
_ => {
|
||||||
skipped += 1;
|
skipped += 1;
|
||||||
@@ -310,12 +308,7 @@ pub async fn handle_analyze_required(
|
|||||||
skipped += 1;
|
skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
events::emit(
|
events::emit(&events_tx, &user.slug, &name, CaseEventKind::AnalysisQueued);
|
||||||
&events_tx,
|
|
||||||
&user.slug,
|
|
||||||
&name,
|
|
||||||
CaseEventKind::AnalysisQueued,
|
|
||||||
);
|
|
||||||
ok += 1;
|
ok += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ struct CaseFlags {
|
|||||||
analysis_stale_with_doc: bool,
|
analysis_stale_with_doc: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn compute_flags(
|
async fn compute_flags(
|
||||||
case_dir: &Path,
|
case_dir: &Path,
|
||||||
recordings: &[RecordingView],
|
recordings: &[RecordingView],
|
||||||
@@ -455,6 +456,7 @@ async fn compute_flags(
|
|||||||
worker_busy: bool,
|
worker_busy: bool,
|
||||||
idle_threshold: std::time::Duration,
|
idle_threshold: std::time::Duration,
|
||||||
now_systime: std::time::SystemTime,
|
now_systime: std::time::SystemTime,
|
||||||
|
boot_time: std::time::SystemTime,
|
||||||
) -> CaseFlags {
|
) -> CaseFlags {
|
||||||
let has_document = any_document_exists(case_dir).await;
|
let has_document = any_document_exists(case_dir).await;
|
||||||
// Honest status: an input file alone does not mean a job is in flight.
|
// Honest status: an input file alone does not mean a job is in flight.
|
||||||
@@ -496,7 +498,8 @@ async fn compute_flags(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let state = auto_trigger::evaluate_state(case_dir, idle_threshold, now_systime).await;
|
let state =
|
||||||
|
auto_trigger::evaluate_state(case_dir, idle_threshold, now_systime, boot_time).await;
|
||||||
let analysis_stale_with_doc = matches!(
|
let analysis_stale_with_doc = matches!(
|
||||||
state,
|
state,
|
||||||
auto_trigger::CaseAnalysisState::Required { has_document: true }
|
auto_trigger::CaseAnalysisState::Required { has_document: true }
|
||||||
@@ -603,6 +606,7 @@ pub async fn handle_my_cases(
|
|||||||
State(events_tx): State<EventSender>,
|
State(events_tx): State<EventSender>,
|
||||||
State(http_client): State<reqwest::Client>,
|
State(http_client): State<reqwest::Client>,
|
||||||
State(vocab): State<Arc<Gazetteer>>,
|
State(vocab): State<Arc<Gazetteer>>,
|
||||||
|
State(boot_time): State<crate::BootTime>,
|
||||||
Query(query): Query<CaseListQuery>,
|
Query(query): Query<CaseListQuery>,
|
||||||
) -> Result<Html<String>, AppError> {
|
) -> Result<Html<String>, AppError> {
|
||||||
let user_root = config.data_path.join(&user.slug);
|
let user_root = config.data_path.join(&user.slug);
|
||||||
@@ -626,6 +630,7 @@ pub async fn handle_my_cases(
|
|||||||
&user_root,
|
&user_root,
|
||||||
idle_threshold,
|
idle_threshold,
|
||||||
now,
|
now,
|
||||||
|
boot_time.0,
|
||||||
&pipeline.analyze_tx,
|
&pipeline.analyze_tx,
|
||||||
&events_tx,
|
&events_tx,
|
||||||
)
|
)
|
||||||
@@ -653,6 +658,7 @@ pub async fn handle_my_cases(
|
|||||||
show_closed,
|
show_closed,
|
||||||
retention.auto_delete_days,
|
retention.auto_delete_days,
|
||||||
crate::analyze::backend::any_backend_available(),
|
crate::analyze::backend::any_backend_available(),
|
||||||
|
boot_time.0,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let total = cases.len();
|
let total = cases.len();
|
||||||
@@ -707,6 +713,7 @@ pub async fn handle_case_page(
|
|||||||
State(events_tx): State<EventSender>,
|
State(events_tx): State<EventSender>,
|
||||||
State(http_client): State<reqwest::Client>,
|
State(http_client): State<reqwest::Client>,
|
||||||
State(vocab): State<Arc<Gazetteer>>,
|
State(vocab): State<Arc<Gazetteer>>,
|
||||||
|
State(boot_time): State<crate::BootTime>,
|
||||||
CaseIdPath(case_id): CaseIdPath,
|
CaseIdPath(case_id): CaseIdPath,
|
||||||
Query(query): Query<CaseListQuery>,
|
Query(query): Query<CaseListQuery>,
|
||||||
) -> Result<Html<String>, AppError> {
|
) -> Result<Html<String>, AppError> {
|
||||||
@@ -749,6 +756,7 @@ pub async fn handle_case_page(
|
|||||||
&case_dir,
|
&case_dir,
|
||||||
idle_threshold,
|
idle_threshold,
|
||||||
now,
|
now,
|
||||||
|
boot_time.0,
|
||||||
&pipeline.analyze_tx,
|
&pipeline.analyze_tx,
|
||||||
&events_tx,
|
&events_tx,
|
||||||
)
|
)
|
||||||
@@ -777,6 +785,7 @@ pub async fn handle_case_page(
|
|||||||
a_busy,
|
a_busy,
|
||||||
idle_threshold,
|
idle_threshold,
|
||||||
now,
|
now,
|
||||||
|
boot_time.0,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -975,6 +984,7 @@ pub(crate) async fn locate_closed_case_or_404(
|
|||||||
/// behaviour for the show-closed view. `auto_delete_days` is used to
|
/// behaviour for the show-closed view. `auto_delete_days` is used to
|
||||||
/// compute the per-case `days_until_purge` countdown; pass 0 to
|
/// compute the per-case `days_until_purge` countdown; pass 0 to
|
||||||
/// disable the countdown (maps to `None` on the view).
|
/// disable the countdown (maps to `None` on the view).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn scan_user_cases(
|
async fn scan_user_cases(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
@@ -982,6 +992,7 @@ async fn scan_user_cases(
|
|||||||
include_closed: bool,
|
include_closed: bool,
|
||||||
auto_delete_days: u32,
|
auto_delete_days: u32,
|
||||||
llm_configured: bool,
|
llm_configured: bool,
|
||||||
|
boot_time: std::time::SystemTime,
|
||||||
) -> Vec<UserCaseView> {
|
) -> Vec<UserCaseView> {
|
||||||
let user_root = config.data_path.join(slug);
|
let user_root = config.data_path.join(slug);
|
||||||
let mut cases = Vec::new();
|
let mut cases = Vec::new();
|
||||||
@@ -1007,6 +1018,7 @@ async fn scan_user_cases(
|
|||||||
now,
|
now,
|
||||||
idle_threshold,
|
idle_threshold,
|
||||||
now_systime,
|
now_systime,
|
||||||
|
boot_time,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -1053,6 +1065,7 @@ async fn compute_case_view(
|
|||||||
now: OffsetDateTime,
|
now: OffsetDateTime,
|
||||||
idle_threshold: std::time::Duration,
|
idle_threshold: std::time::Duration,
|
||||||
now_systime: std::time::SystemTime,
|
now_systime: std::time::SystemTime,
|
||||||
|
boot_time: std::time::SystemTime,
|
||||||
) -> Option<UserCaseView> {
|
) -> Option<UserCaseView> {
|
||||||
let recordings = scan_recordings(case_path).await;
|
let recordings = scan_recordings(case_path).await;
|
||||||
if recordings.is_empty() {
|
if recordings.is_empty() {
|
||||||
@@ -1078,15 +1091,21 @@ async fn compute_case_view(
|
|||||||
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
|
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
|
||||||
let can_analyze = !analyzing && all_transcribed && llm_configured;
|
let can_analyze = !analyzing && all_transcribed && llm_configured;
|
||||||
|
|
||||||
let state = auto_trigger::evaluate_state(case_path, idle_threshold, now_systime).await;
|
let (is_closed, days_until_purge) = closed_state(case_path, auto_delete_days, now).await;
|
||||||
let analysis_required = matches!(
|
|
||||||
|
let state =
|
||||||
|
auto_trigger::evaluate_state(case_path, idle_threshold, now_systime, boot_time).await;
|
||||||
|
// Closed cases must never advertise as "Analyse erforderlich" — the
|
||||||
|
// whole point of closing a case is to freeze it. The bulk-endpoint
|
||||||
|
// already skips closed cases in its enqueue loop; this keeps the UI
|
||||||
|
// classification in lockstep so the badge / required_count match.
|
||||||
|
let analysis_required = !is_closed
|
||||||
|
&& matches!(
|
||||||
state,
|
state,
|
||||||
crate::analyze::auto_trigger::CaseAnalysisState::Required { .. }
|
crate::analyze::auto_trigger::CaseAnalysisState::Required { .. }
|
||||||
| crate::analyze::auto_trigger::CaseAnalysisState::Idle { .. }
|
| crate::analyze::auto_trigger::CaseAnalysisState::Idle { .. }
|
||||||
);
|
);
|
||||||
|
|
||||||
let (is_closed, days_until_purge) = closed_state(case_path, auto_delete_days, now).await;
|
|
||||||
|
|
||||||
// One extra read per case with a document. Documents are in the KB
|
// One extra read per case with a document. Documents are in the KB
|
||||||
// range — negligible next to the existing scan_recordings I/O — and
|
// range — negligible next to the existing scan_recordings I/O — and
|
||||||
// we skip the read entirely when the stat above said no document.
|
// we skip the read entirely when the stat above said no document.
|
||||||
|
|||||||
@@ -629,6 +629,7 @@ async fn recovery_does_not_enqueue_at_boot() {
|
|||||||
&tmp,
|
&tmp,
|
||||||
std::time::Duration::ZERO,
|
std::time::Duration::ZERO,
|
||||||
std::time::SystemTime::now(),
|
std::time::SystemTime::now(),
|
||||||
|
std::time::SystemTime::UNIX_EPOCH,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -1243,6 +1244,7 @@ async fn recovery_skips_closed_cases() {
|
|||||||
&tmp,
|
&tmp,
|
||||||
std::time::Duration::ZERO,
|
std::time::Duration::ZERO,
|
||||||
std::time::SystemTime::now(),
|
std::time::SystemTime::now(),
|
||||||
|
std::time::SystemTime::UNIX_EPOCH,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -1509,3 +1511,104 @@ async fn analyze_works_against_ollama_style_endpoint_without_api_key() {
|
|||||||
drop(tx);
|
drop(tx);
|
||||||
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
|
let _ = tokio::time::timeout(Duration::from_secs(2), handle).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Bulk "Aktualisieren" endpoint
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `POST /cases/analyze-required` walks every UUID-named case and force-
|
||||||
|
/// enqueues those in `Required`/`Idle`. `Failed` and `Current` cases must
|
||||||
|
/// stay untouched. We probe the resulting state via the on-disk
|
||||||
|
/// `analysis_input.json` marker — written before the channel send, so its
|
||||||
|
/// presence is the canonical "would have been enqueued" signal.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn analyze_required_endpoint_enqueues_only_required_cases() {
|
||||||
|
use doctate_server::analyze::auto_trigger::FailureMarker;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
|
||||||
|
fn to_rfc3339(t: std::time::SystemTime) -> String {
|
||||||
|
OffsetDateTime::from(t).format(&Rfc3339).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
let (cfg, settings) = cfg_llm("ar-1");
|
||||||
|
let user_root = cfg.data_path.join("dr_a");
|
||||||
|
|
||||||
|
// Required: transcribed recording, no document.
|
||||||
|
let req_id = "11111111-1111-1111-1111-111111111111";
|
||||||
|
let req_dir = seed_case(&cfg.data_path, "dr_a", req_id);
|
||||||
|
seed_recording(&req_dir, "2026-04-15T10-00-00Z", Some("erste Aufnahme"));
|
||||||
|
|
||||||
|
// Current: document covers the recording's mtime exactly.
|
||||||
|
let cur_id = "22222222-2222-2222-2222-222222222222";
|
||||||
|
let cur_dir = seed_case(&cfg.data_path, "dr_a", cur_id);
|
||||||
|
seed_recording(&cur_dir, "2026-04-15T10-00-00Z", Some("alles okay"));
|
||||||
|
let cur_m4a_mtime = std::fs::metadata(cur_dir.join("2026-04-15T10-00-00Z.m4a"))
|
||||||
|
.unwrap()
|
||||||
|
.modified()
|
||||||
|
.unwrap();
|
||||||
|
write_document_for_test(&cur_dir, "fertig", &to_rfc3339(cur_m4a_mtime));
|
||||||
|
|
||||||
|
// Failed: failure marker matches latest recording mtime.
|
||||||
|
let fail_id = "33333333-3333-3333-3333-333333333333";
|
||||||
|
let fail_dir = seed_case(&cfg.data_path, "dr_a", fail_id);
|
||||||
|
seed_recording(&fail_dir, "2026-04-15T10-00-00Z", Some("kaputt"));
|
||||||
|
let fail_m4a_mtime = std::fs::metadata(fail_dir.join("2026-04-15T10-00-00Z.m4a"))
|
||||||
|
.unwrap()
|
||||||
|
.modified()
|
||||||
|
.unwrap();
|
||||||
|
let marker = FailureMarker {
|
||||||
|
last_recording_mtime: to_rfc3339(fail_m4a_mtime),
|
||||||
|
reason: "test".into(),
|
||||||
|
failed_at: "2026-04-15T10-05-00Z".into(),
|
||||||
|
backend_id: None,
|
||||||
|
details: None,
|
||||||
|
};
|
||||||
|
std::fs::write(
|
||||||
|
fail_dir.join(".analysis_failed.json"),
|
||||||
|
serde_json::to_vec(&marker).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (app, store) = doctate_server::create_router_and_session_store_with_settings(cfg, settings);
|
||||||
|
let (cookie, csrf) = login_with_csrf(&app, &store, "dr_a", "s").await;
|
||||||
|
|
||||||
|
let resp = app
|
||||||
|
.oneshot(csrf_form_post(paths::ANALYZE_REQUIRED, &cookie, &csrf, ""))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||||
|
assert_eq!(
|
||||||
|
resp.headers().get(header::LOCATION).unwrap(),
|
||||||
|
"/cases",
|
||||||
|
"must redirect back to the case list"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Required → analysis_input.json was written, the case is now Queued.
|
||||||
|
assert!(
|
||||||
|
user_root.join(req_id).join(ANALYSIS_INPUT_FILE).exists(),
|
||||||
|
"Required case must have been enqueued"
|
||||||
|
);
|
||||||
|
// Current → no enqueue, document.json untouched.
|
||||||
|
assert!(
|
||||||
|
!user_root.join(cur_id).join(ANALYSIS_INPUT_FILE).exists(),
|
||||||
|
"Current case must not be enqueued"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
user_root.join(cur_id).join(DOCUMENT_FILE).exists(),
|
||||||
|
"Current case's document must remain in place"
|
||||||
|
);
|
||||||
|
// Failed → no enqueue. Marker stays so the user sees the failure
|
||||||
|
// banner on the case page and can retry per-backend manually.
|
||||||
|
assert!(
|
||||||
|
!user_root.join(fail_id).join(ANALYSIS_INPUT_FILE).exists(),
|
||||||
|
"Failed case must not be enqueued"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
user_root
|
||||||
|
.join(fail_id)
|
||||||
|
.join(".analysis_failed.json")
|
||||||
|
.exists(),
|
||||||
|
"failure marker must persist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ pub const LOGOUT: &str = "/logout";
|
|||||||
pub const CASES: &str = "/cases";
|
pub const CASES: &str = "/cases";
|
||||||
/// `POST /cases/bulk` — admin bulk action (close/analyze/reset).
|
/// `POST /cases/bulk` — admin bulk action (close/analyze/reset).
|
||||||
pub const BULK: &str = "/cases/bulk";
|
pub const BULK: &str = "/cases/bulk";
|
||||||
|
/// `POST /cases/analyze-required` — user-facing bulk "Aktualisieren"
|
||||||
|
/// button: enqueues every Required/Idle case for the authenticated user.
|
||||||
|
pub const ANALYZE_REQUIRED: &str = "/cases/analyze-required";
|
||||||
/// `POST /cases/purge-closed` — admin hard-delete of closed cases.
|
/// `POST /cases/purge-closed` — admin hard-delete of closed cases.
|
||||||
pub const PURGE_CLOSED: &str = "/cases/purge-closed";
|
pub const PURGE_CLOSED: &str = "/cases/purge-closed";
|
||||||
/// `GET /events` — SSE stream (per-user scoped).
|
/// `GET /events` — SSE stream (per-user scoped).
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ fn build_state_with_stores() -> AppState {
|
|||||||
events_tx: doctate_server::events::channel(),
|
events_tx: doctate_server::events::channel(),
|
||||||
http_client: reqwest::Client::new(),
|
http_client: reqwest::Client::new(),
|
||||||
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
|
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
|
||||||
|
boot_time: doctate_server::BootTime(std::time::SystemTime::UNIX_EPOCH),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ fn build_app_with_events_tx(
|
|||||||
events_tx,
|
events_tx,
|
||||||
http_client: reqwest::Client::new(),
|
http_client: reqwest::Client::new(),
|
||||||
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
|
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
|
||||||
|
boot_time: doctate_server::BootTime(std::time::SystemTime::UNIX_EPOCH),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user