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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::config::Config;
|
||||
use crate::csrf::{CsrfForm, HasCsrfToken};
|
||||
use crate::error::AppError;
|
||||
use crate::events::{self, CaseEventKind, EventSender};
|
||||
use crate::oneliner_locks::OnelinerLocks;
|
||||
use crate::paths::{CloseMarker, write_close_marker};
|
||||
use crate::routes::case_actions::{
|
||||
build_analysis_input, reset_case_artefacts, write_input_create_new,
|
||||
@@ -45,6 +46,7 @@ pub async fn handle_bulk_action(
|
||||
State(config): State<Arc<Config>>,
|
||||
State(analyze_tx): State<AnalyzeSender>,
|
||||
State(events_tx): State<EventSender>,
|
||||
State(locks): State<OnelinerLocks>,
|
||||
CsrfForm(form): CsrfForm<BulkForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
// Bulk actions are admin-only across the board: the UI hides the
|
||||
@@ -78,7 +80,9 @@ pub async fn handle_bulk_action(
|
||||
.await
|
||||
}
|
||||
BulkAction::Close => bulk_close(&events_tx, &user.slug, &user_root, &form.case_ids).await,
|
||||
BulkAction::Reset => bulk_reset(&events_tx, &user.slug, &user_root, &form.case_ids).await,
|
||||
BulkAction::Reset => {
|
||||
bulk_reset(&events_tx, &locks, &user.slug, &user_root, &form.case_ids).await
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Redirect::to("/web/cases"))
|
||||
@@ -185,6 +189,7 @@ async fn bulk_close(
|
||||
|
||||
async fn bulk_reset(
|
||||
events_tx: &EventSender,
|
||||
locks: &OnelinerLocks,
|
||||
slug: &str,
|
||||
user_root: &std::path::Path,
|
||||
case_ids: &[String],
|
||||
@@ -202,7 +207,7 @@ async fn bulk_reset(
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
if let Err(e) = reset_case_artefacts(&case_dir).await {
|
||||
if let Err(e) = reset_case_artefacts(&case_dir, locks).await {
|
||||
warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-reset: failed");
|
||||
skipped += 1;
|
||||
continue;
|
||||
|
||||
@@ -12,7 +12,6 @@ use tokio::io::AsyncWriteExt;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use doctate_common::TranscriptState;
|
||||
use doctate_common::oneliners::ONELINER_FILENAME;
|
||||
use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339};
|
||||
|
||||
use crate::analyze::{
|
||||
@@ -24,8 +23,10 @@ use crate::config::Config;
|
||||
use crate::csrf::{CsrfForm, CsrfOnlyForm, HasCsrfToken};
|
||||
use crate::error::AppError;
|
||||
use crate::events::{self, CaseEventKind, EventSender};
|
||||
use crate::oneliner_locks::OnelinerLocks;
|
||||
use crate::paths::{
|
||||
CLOSE_MARKER, CloseMarker, read_close_marker, read_transcript_state, write_close_marker,
|
||||
CLOSE_MARKER, CloseMarker, delete_oneliner_unless_manual, read_close_marker,
|
||||
read_transcript_state, write_close_marker,
|
||||
};
|
||||
use crate::routes::user_web::locate_case_or_404;
|
||||
use crate::routes::web::validate_filename;
|
||||
@@ -297,18 +298,27 @@ pub(crate) async fn read_document(case_dir: &Path) -> Option<String> {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Reset a case to its raw audio: delete all derived artefacts
|
||||
/// (transcripts, oneliner, analysis input, document) and rename every
|
||||
/// Reset a case to its raw audio: delete derived artefacts
|
||||
/// (transcripts, analysis input, document, non-Manual oneliner) and rename every
|
||||
/// `*.m4a.failed` back to `*.m4a` so the transcribe self-heal picks it
|
||||
/// up again. Idempotent: missing files are no-ops.
|
||||
pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()> {
|
||||
for name in [DOCUMENT_FILE, ANALYSIS_INPUT_FILE, ONELINER_FILENAME] {
|
||||
///
|
||||
/// `oneliner.json` is special: a [`OnelinerState::Manual`] override is
|
||||
/// preserved (per the sticky-Manual rule). Routed through
|
||||
/// [`delete_oneliner_unless_manual`] so the per-case lock serializes
|
||||
/// the wipe with concurrent worker writes.
|
||||
pub(crate) async fn reset_case_artefacts(
|
||||
case_dir: &Path,
|
||||
locks: &OnelinerLocks,
|
||||
) -> std::io::Result<()> {
|
||||
for name in [DOCUMENT_FILE, ANALYSIS_INPUT_FILE] {
|
||||
match tokio::fs::remove_file(case_dir.join(name)).await {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
delete_oneliner_unless_manual(case_dir, locks).await?;
|
||||
let mut entries = tokio::fs::read_dir(case_dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name();
|
||||
@@ -431,6 +441,7 @@ pub async fn handle_reset_case(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(events_tx): State<EventSender>,
|
||||
State(locks): State<OnelinerLocks>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
CsrfForm(form): CsrfForm<CsrfOnlyForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
@@ -441,7 +452,7 @@ pub async fn handle_reset_case(
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "reset").await?;
|
||||
|
||||
reset_case_artefacts(&case_dir)
|
||||
reset_case_artefacts(&case_dir, &locks)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("reset: {e}")))?;
|
||||
|
||||
@@ -574,6 +585,7 @@ pub async fn handle_delete_recording(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(events_tx): State<EventSender>,
|
||||
State(locks): State<OnelinerLocks>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
CsrfForm(form): CsrfForm<DeleteRecordingForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
@@ -600,7 +612,11 @@ pub async fn handle_delete_recording(
|
||||
for p in &targets {
|
||||
remove_if_exists(p).await?;
|
||||
}
|
||||
remove_if_exists(&case_dir.join(ONELINER_FILENAME)).await?;
|
||||
// Oneliner is the only artefact carrying a possibly-manual override —
|
||||
// route through the wrapper so a clinician's edit survives the wipe.
|
||||
delete_oneliner_unless_manual(&case_dir, &locks)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("oneliner: {e}")))?;
|
||||
|
||||
info!(
|
||||
slug = %user.slug,
|
||||
|
||||
Reference in New Issue
Block a user