Desktop: queued files can be deleted/renamed; only running jobs lock

inFlight conflated queued with actually-running, so a file waiting in
the pump refused deletes ('is transcribing' — false) and hid its
Rename/Delete buttons during a batch. Track the truly-running files in
a dedicated set: the pump registers a file at poll-time atomically with
the queue pop, and deleteFile/renameFile either find it running (refuse)
or cancel its queue entry under the same lock (proceed). Batch progress
shrinks when a queued file is cancelled (batchRemove) so the bar still
ends; row status now distinguishes QUEUED from RUNNING on rescan.
This commit is contained in:
avi 2026-09-15 10:15:28 -05:00
commit 36a1d2374b
4 changed files with 107 additions and 24 deletions

View file

@ -133,6 +133,16 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
/** Files the pump is running or has queued this session (by name). */
private val inFlight = mutableSetOf<String>()
/** Files the pump/detail job is working RIGHT NOW (upload/transcribe
* running engine-side). Queued-but-untouched files are NOT here:
* they can be cancelled by deleting. Guarded by [runningGate]. */
private val runningGate = Object()
private val runningSet = mutableSetOf<String>()
private fun isRunning(name: String): Boolean =
synchronized(runningGate) { name in runningSet }
private val runningNames: Set<String>
get() = synchronized(runningGate) { runningSet.toSet() }
/** Files whose last auto attempt failed (no hot-loop retries). */
private val failedFiles = mutableSetOf<String>()
@ -455,7 +465,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
status = when {
done -> FileStatus.DONE
f.name in failedFiles -> FileStatus.FAILED
f.name in inFlight -> FileStatus.RUNNING
isRunning(f.name) -> FileStatus.RUNNING
f.name in inFlight -> FileStatus.QUEUED
else -> FileStatus.NEW
},
)
@ -545,10 +556,19 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
if (pumpJob?.isActive == true) return
pumpJob = scope.launch {
while (queue.isNotEmpty() && _connected.value) {
val f = queue.poll() ?: break
// Poll + register-running are atomic against deleteFile's
// check+remove (same lock), so a delete can never slip
// between the two and yank the file out from under us.
val f = synchronized(runningGate) {
queue.poll()?.also { runningSet += it.name }
} ?: break
try {
val ok = transcribeFile(f)
if (!ok) failedFiles += f.name
else failedFiles -= f.name
} finally {
synchronized(runningGate) { runningSet -= f.name }
}
inFlight -= f.name
_batch.value = LibraryQueue.batchAdvance(_batch.value, f.name)
rescanStatuses()
@ -654,7 +674,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
* race the rename. Returns null on success or a human-readable reason.
*/
fun renameFile(old: File, newBase: String): String? {
if (old.name in inFlight) return "Wait — this file is transcribing."
// Queued-but-unstarted renames are fine: cancel the queue entry
// first (same lock as the pump) so it can't start mid-rename.
val running = synchronized(runningGate) {
if (old.name in runningSet) true
else { queue.remove(old); inFlight -= old.name; false }
}
if (running) return "Wait — this file is transcribing."
val target = LibraryQueue.renameTarget(old, newBase)
.getOrElse { return it.message ?: "Invalid name." }
val ok = runCatching {
@ -683,14 +709,21 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
* Delete a library recording for good: the audio, its report, and its
* mapping sidecar go now; the server record (when this file was ever
* transcribed) is purged in the background — a failure there surfaces
* on [deleteWarning] rather than blocking the delete. Refuses while
* the file is queued/transcribing — the pump would resurrect it
* mid-flight. Returns null on success or a human-readable refusal.
* on [deleteWarning] rather than blocking the delete. Refuses only
* while the file is ACTUALLY transcribing (the engine has it mid-job);
* a merely-queued file is cancelled out of the pump and deleted.
* Returns null on success or a human-readable refusal.
*/
fun deleteFile(f: File): String? {
_deleteWarning.value = null
if (playingFile.value?.absolutePath == f.absolutePath) stopPlayback()
if (f.name in inFlight) return "Wait — this file is transcribing."
// Atomic against the pump's poll+register: if it isn't running we
// pull it from the queue now, so it can never start after this point.
val running = synchronized(runningGate) {
if (f.name in runningSet) true
else { queue.remove(f); inFlight -= f.name; false }
}
if (running) return "Wait — this file is transcribing."
if (!f.exists()) { rescan(); return "That file is already gone." }
val remoteId = _detail.value?.takeIf { it.file == f }?.remoteId
@ -704,8 +737,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
// Bookkeeping keyed by name — drop every trace so a re-added file
// with the same name starts clean.
failedFiles -= f.name
inFlight -= f.name
queue.remove(f)
_batch.value = LibraryQueue.batchRemove(_batch.value, f.name)
_liveProgress.value = _liveProgress.value.toMutableMap().apply { remove(f.name) }
if (_detail.value?.file == f) {
pollJob?.cancel()
@ -748,7 +780,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
* summary of what was skipped/failed.
*/
fun deleteFiles(files: List<File>): String? {
val (now, waiting) = LibraryQueue.planDelete(files, inFlight.toSet())
val (now, waiting) = LibraryQueue.planDelete(files, runningNames)
val failed = now.mapNotNull { deleteFile(it) }
val notes = buildList {
waiting.forEach { add("\"${it.nameWithoutExtension}\" is transcribing — not deleted") }
@ -1029,6 +1061,15 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
return
}
pollJob = scope.launch {
synchronized(runningGate) { runningSet += d.file.name }
try { transcribeNow(d) } finally {
synchronized(runningGate) { runningSet -= d.file.name }
}
}
}
private suspend fun transcribeNow(d: DetailUi) {
run {
d.busy = "Uploading…"
d.uploadProgress = 0f
d.error = null
@ -1057,7 +1098,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
d.busy = null
d.error = it.message ?: "Upload failed."
_detail.value = d.copy()
return@launch
return@run
}
d.remoteId = ref.key
saveMapping(d.file, RemoteMapping(ref.key, model = d.overrideModel,
@ -1088,7 +1129,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
d.busy = null
d.error = failed.error ?: "Transcription failed."
_detail.value = d.copy()
return@launch
return@run
}
d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) }
.getOrNull()
@ -1117,6 +1158,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
val remoteId = d0.remoteId ?: return
pollJob?.cancel()
pollJob = scope.launch {
synchronized(runningGate) { runningSet += d0.file.name }
try {
d0.busy = if (job == "transcribe") "Re-transcribing…" else "Summarizing…"
d0.uploadProgress = null
d0.error = null
@ -1173,6 +1216,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
title = d0.file.nameWithoutExtension))
rescan()
}
} finally {
synchronized(runningGate) { runningSet -= d0.file.name }
}
}
}

View file

@ -104,6 +104,7 @@ object LibraryQueue {
/**
* One completed file in an active batch: returns the updated batch, or
* null when the batch just finished (done == total). Null in → null out.
* A cancelled file leaves the batch entirely via [batchRemove].
*/
fun batchAdvance(batch: TranscribeBatch?, fileName: String): TranscribeBatch? {
if (batch == null || fileName !in batch.names) return batch
@ -111,13 +112,25 @@ object LibraryQueue {
return if (done >= batch.total) null else batch.copy(done = done)
}
/** Split a selection into what can go now vs. files that must wait
* because the pump has them in flight. */
/** A queued batch file was cancelled (deleted before its turn): shrink
* the batch so the progress bar still reaches the end. */
fun batchRemove(batch: TranscribeBatch?, fileName: String): TranscribeBatch? {
if (batch == null || fileName !in batch.names) return batch
val total = batch.total - 1
return if (batch.done >= total) null
else batch.copy(names = batch.names - fileName, total = total)
}
/**
* Split a selection into what can go now vs. files the engine is
* actively transcribing ([runningNames]). Files merely QUEUED in the
* pump are deletable — removing them from the queue is a clean cancel.
*/
fun planDelete(
selected: List<File>,
inFlightNames: Set<String>,
runningNames: Set<String>,
): Pair<List<File>, List<File>> =
selected.partition { it.name !in inFlightNames }
selected.partition { it.name !in runningNames }
/** Audio candidates: regular files, known extension, not hidden. */
fun isAudioCandidate(f: File, audioExts: Set<String>): Boolean =

View file

@ -399,8 +399,10 @@ fun LibraryScreen(state: DesktopState) {
Text("Transcribe")
}
}
if (e.status != FileStatus.RUNNING &&
e.status != FileStatus.QUEUED) {
// Queued files stay editable/deletable: deleting
// cancels them out of the pump. Only a file the
// engine is actually working is locked.
if (e.status != FileStatus.RUNNING) {
TextButton({ renaming = e.file }) {
Icon(Icons.Filled.Edit, contentDescription = null,
modifier = Modifier.size(18.dp))
@ -410,8 +412,7 @@ fun LibraryScreen(state: DesktopState) {
}
// No "Open" button: clicking the card opens the
// recording (the button was redundant).
if (e.status != FileStatus.RUNNING &&
e.status != FileStatus.QUEUED) {
if (e.status != FileStatus.RUNNING) {
TextButton(
{ deleting = e.file },
content = {

View file

@ -66,15 +66,25 @@ class LibraryQueueTest {
LibraryQueue.selectOrClearAll(emptySet(), emptyList()))
}
@Test fun planDeleteHoldsBackInFlightFiles() {
@Test fun planDeleteHoldsBackOnlyActuallyRunningFiles() {
val dir = createTempDirectory("lqp").toFile()
val a = File(dir, "a.m4a").apply { writeText("x") }
val b = File(dir, "b.m4a").apply { writeText("x") }
// b is running engine-side; a is NEW or merely QUEUED — both go.
val (now, waiting) = LibraryQueue.planDelete(listOf(a, b), setOf("b.m4a"))
assertEquals(listOf(a), now)
assertEquals(listOf(b), waiting)
}
@Test fun planDeleteReleasesQueuedFiles() {
val dir = createTempDirectory("lqp2").toFile()
val a = File(dir, "a.m4a").apply { writeText("x") }
// Empty running set: queued files are cancellable, so all delete.
val (now, waiting) = LibraryQueue.planDelete(listOf(a), emptySet())
assertEquals(listOf(a), now)
assertTrue(waiting.isEmpty())
}
@Test fun autoQueueSkipsReportsInFlightAndFailed() {
val dir = createTempDirectory("lq").toFile()
val fresh = File(dir, "fresh.m4a").apply { writeText("x") }
@ -160,4 +170,17 @@ class TranscribeBatchTest {
assertEquals(0, LibraryQueue.batchAdvance(b, "z.m4a")?.done)
assertEquals(null, LibraryQueue.batchAdvance(null, "a.m4a"))
}
@Test fun removeShrinksBatchForCancelledFile() {
val b = TranscribeBatch(setOf("a.m4a", "b.m4a", "c.m4a"), 1, 3)
val after = LibraryQueue.batchRemove(b, "c.m4a")
assertEquals(2, after?.total)
assertEquals(1, after?.done)
assertTrue(after?.names?.contains("c.m4a") == false)
// Cancelling the last outstanding file completes the batch.
assertEquals(null, LibraryQueue.batchRemove(b.copy(done = 2), "c.m4a"))
// Unknown file / null batch are no-ops.
assertEquals(b, LibraryQueue.batchRemove(b, "z.m4a"))
assertEquals(null, LibraryQueue.batchRemove(null, "a.m4a"))
}
}