fix: Preserve manual oneliner across delete and reset paths

Consolidate the sticky-Manual rule for oneliner.json into a single
helper paths::delete_oneliner_unless_manual that holds the per-case
lock, reads the state, keeps OnelinerState::Manual, and removes any
LLM-derived variant. Three previously-direct deletion paths now route
through it: handle_delete_recording, reset_case_artefacts (admin
single-reset), and bulk_reset (admin bulk action). Before this fix a
clinician's manual override was wiped by a recording delete, letting
the LLM auto-regen path overwrite it on the next view.

Tests: 4 unit tests for the wrapper contract, integration tests for
the recording-delete and case-reset endpoints (Manual preserved,
Ready wiped), and a static-scan invariant that fails on direct
remove_file(... ONELINER_FILENAME) calls outside paths.rs.
This commit is contained in:
2026-04-26 22:09:08 +02:00
parent eaed8f0dcd
commit 9a00b592f0
6 changed files with 421 additions and 13 deletions
+130
View File
@@ -4,6 +4,9 @@ use doctate_common::TranscriptState;
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use tracing::info;
use crate::oneliner_locks::OnelinerLocks;
/// Absolute on-disk location of a case directory.
///
@@ -95,6 +98,46 @@ pub async fn write_oneliner_state(case_dir: &Path, state: &OnelinerState) -> std
tokio::fs::rename(&tmp_path, &final_path).await
}
/// Delete `case_dir/oneliner.json` unless it persists a manual override.
///
/// Acquires the per-case oneliner lock, reads the current state, and
/// removes the file only when the on-disk state is missing, malformed,
/// or one of the LLM-derived variants (`Ready`, `Empty`, `Error`).
/// `OnelinerState::Manual` is preserved untouched — once a clinician
/// has set the oneliner by hand, it must survive every server-driven
/// artefact wipe (recording delete, admin reset). The only legitimate
/// path that may remove a manual oneliner is the user-initiated
/// `purge-closed`, which removes the entire case directory and is
/// therefore not routed through this helper.
///
/// Returns `Ok(true)` if the file was deleted (or was already absent),
/// `Ok(false)` if a manual override was preserved.
pub async fn delete_oneliner_unless_manual(
case_dir: &Path,
locks: &OnelinerLocks,
) -> std::io::Result<bool> {
// Hold the per-case lock across read+decide+delete so a concurrent
// worker cannot flip Manual <-> Ready between our check and our
// remove. Worker write paths take the same lock around their own
// re-check window, so the two paths serialize cleanly.
let _guard = locks.lock_for(case_dir).await;
let (state, _) = read_oneliner_state(case_dir).await;
if matches!(state, Some(OnelinerState::Manual { .. })) {
info!(
case_dir = %case_dir.display(),
"manual oneliner preserved across artefact wipe"
);
return Ok(false);
}
match tokio::fs::remove_file(case_dir.join(ONELINER_FILENAME)).await {
Ok(_) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
Err(e) => Err(e),
}
}
/// Load the `.transcript.txt` sidecar for a recording as a
/// [`TranscriptState`].
///
@@ -111,3 +154,90 @@ pub async fn read_transcript_state(audio_stem_path: &Path) -> TranscriptState {
let raw = tokio::fs::read_to_string(&transcript_path).await.ok();
TranscriptState::from_raw(raw.as_deref())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn manual_state() -> OnelinerState {
OnelinerState::Manual {
text: "manuell-arzt".to_owned(),
set_at: "2026-04-26T10:00:00Z".to_owned(),
}
}
fn ready_state() -> OnelinerState {
OnelinerState::Ready {
text: "knee, left, pain".to_owned(),
generated_at: "2026-04-26T10:00:00Z".to_owned(),
}
}
#[tokio::test]
async fn delete_oneliner_unless_manual_keeps_manual() {
let dir = TempDir::new().unwrap();
write_oneliner_state(dir.path(), &manual_state())
.await
.unwrap();
let locks = OnelinerLocks::new();
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
.await
.unwrap();
assert!(!removed, "Manual must be preserved");
let (state, _) = read_oneliner_state(dir.path()).await;
assert!(
matches!(state, Some(OnelinerState::Manual { ref text, .. }) if text == "manuell-arzt"),
"Manual state must remain identical on disk, got {state:?}"
);
}
#[tokio::test]
async fn delete_oneliner_unless_manual_removes_ready() {
let dir = TempDir::new().unwrap();
write_oneliner_state(dir.path(), &ready_state())
.await
.unwrap();
let locks = OnelinerLocks::new();
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
.await
.unwrap();
assert!(removed, "Ready must be deleted");
assert!(!dir.path().join(ONELINER_FILENAME).exists());
}
#[tokio::test]
async fn delete_oneliner_unless_manual_handles_missing_file() {
let dir = TempDir::new().unwrap();
let locks = OnelinerLocks::new();
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
.await
.expect("missing file must not be an error");
assert!(removed, "absent file is reported as deleted");
}
#[tokio::test]
async fn delete_oneliner_unless_manual_handles_malformed_json() {
// Malformed JSON must not lock the case in place forever — the
// file is treated like a non-Manual state and removed so the
// pipeline can re-derive a fresh oneliner.
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join(ONELINER_FILENAME), b"{ not json")
.await
.unwrap();
let locks = OnelinerLocks::new();
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
.await
.unwrap();
assert!(removed, "malformed file must be cleared");
assert!(!dir.path().join(ONELINER_FILENAME).exists());
}
}