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:
2026-05-05 15:12:08 +02:00
parent bd133e9944
commit 16ef0bbe78
10 changed files with 392 additions and 41 deletions
+103
View File
@@ -629,6 +629,7 @@ async fn recovery_does_not_enqueue_at_boot() {
&tmp,
std::time::Duration::ZERO,
std::time::SystemTime::now(),
std::time::SystemTime::UNIX_EPOCH,
)
.await;
@@ -1243,6 +1244,7 @@ async fn recovery_skips_closed_cases() {
&tmp,
std::time::Duration::ZERO,
std::time::SystemTime::now(),
std::time::SystemTime::UNIX_EPOCH,
)
.await;
@@ -1509,3 +1511,104 @@ async fn analyze_works_against_ollama_style_endpoint_without_api_key() {
drop(tx);
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"
);
}
+3
View File
@@ -19,6 +19,9 @@ pub const LOGOUT: &str = "/logout";
pub const CASES: &str = "/cases";
/// `POST /cases/bulk` — admin bulk action (close/analyze/reset).
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.
pub const PURGE_CLOSED: &str = "/cases/purge-closed";
/// `GET /events` — SSE stream (per-user scoped).
+1
View File
@@ -71,6 +71,7 @@ fn build_state_with_stores() -> AppState {
events_tx: doctate_server::events::channel(),
http_client: reqwest::Client::new(),
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
boot_time: doctate_server::BootTime(std::time::SystemTime::UNIX_EPOCH),
}
}
+1
View File
@@ -84,6 +84,7 @@ fn build_app_with_events_tx(
events_tx,
http_client: reqwest::Client::new(),
vocab: Arc::new(doctate_server::gazetteer::Gazetteer::empty()),
boot_time: doctate_server::BootTime(std::time::SystemTime::UNIX_EPOCH),
}
}