diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 8b391ba0..4b299abc 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -123,7 +123,10 @@ data class SessionRenameRequest( @Serializable data class BulkDeleteRequest( val ids: List, - val delete_all: Boolean = false, + // Destructive POSTs read the profile off the BODY (not the ?profile= + // query rewrite) — an unnamed bulk delete is refused with 400 on a + // multi-profile backend. Null stays valid for single-profile hosts. + val profile: String? = null, ) @Serializable @@ -134,5 +137,13 @@ data class BulkDeleteResponse( @Serializable data class PruneRequest( - val days: Int, + // Server contract: `older_than_days` (hermes_cli/web_models.py SessionPrune). + // The old `days` key was unknown to the backend, so the user's choice was + // silently dropped and the server pruned its own 90-day default. + @SerialName("older_than_days") val olderThanDays: Int, + // Destructive POSTs read the profile off the BODY (not the ?profile= query + // rewrite): on a multi-profile backend an unnamed prune is refused with + // 400 (web_routers/_common.py destructive_profile). Null stays valid for + // single-profile hosts. + val profile: String? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt index 3c407a83..3202cadc 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptor.kt @@ -33,6 +33,10 @@ object ProfileScopeInterceptor : Interceptor { // multi-profile host transcribes under the launch profile // (review, PR #1250). "/api/audio", + // Curator state is per profile (config-scoped reads) and the run + // trigger is privileged: `POST /api/curator/run` is refused with + // 400 on a multi-profile backend without ?profile=. + "/api/curator", "/api/config", "/api/credentials/pool", "/api/cron", diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 5ea87d77..804446cf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -66,6 +66,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -507,6 +508,24 @@ fun SessionsScreen( // (The scaffold already applies top-bar padding via its inner Box, so we // must NOT re-apply paddingValues here.) Column(modifier = Modifier.fillMaxSize()) { + // Prune can take a while on large histories — show it's working. + if (state.isPruning) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = spacing.md, vertical = spacing.sm) + .testTag("sessions_pruning_indicator"), + verticalArrangement = Arrangement.spacedBy(spacing.xs), + ) { + Text( + text = stringResource(R.string.sessions_pruning), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } Row( modifier = Modifier diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 87363b65..65cae410 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -963,7 +963,13 @@ class SessionsViewModel( val result = safeApiCall { ApiClient.hermesApi.bulkDeleteSessions( - body = BulkDeleteRequest(ids = ids), + body = + BulkDeleteRequest( + ids = ids, + // Destructive POSTs scope by BODY profile; the + // ?profile= query rewrite never reaches them. + profile = AuthManager.activeProfileId.value, + ), ) } when (result) { @@ -1021,7 +1027,16 @@ class SessionsViewModel( val result = safeApiCall { ApiClient.hermesApi.pruneSessions( - body = PruneRequest(days = days), + body = + PruneRequest( + // The server model is `older_than_days`; the + // old `days` key was silently ignored and the + // backend pruned its own 90-day default. + olderThanDays = days, + // Destructive POSTs scope by BODY profile; the + // ?profile= query rewrite never reaches them. + profile = AuthManager.activeProfileId.value, + ), ) } when (result) { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 6ff8169f..4246ab8e 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -759,6 +759,7 @@ حذف الجلسات الأقدم من عدد الأيام المحدد. حد الأيام تنقيح +جارٍ تنقيح الجلسات القديمة… فارغة هل تريد حذف الجلسات الفارغة؟ حذف %1$d من الجلسات الفارغة. سيتم الاحتفاظ بالجلسات النشطة والمؤرشفة. diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 395700da..4a3e9465 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -646,6 +646,7 @@ 지정된 일수보다 오래된 세션을 삭제합니다. 일수 기준 정리 + 오래된 세션 정리 중… 분기된 세션 세션 삭제? 정말 \"%1$s\"을(를) 삭제하시겠습니까? 되돌릴 수 없습니다. diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 34212136..032fd94e 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -730,6 +730,7 @@ 删除早于指定天数的会话。 天数阈值 清理 + 正在清理旧会话… 空会话 删除空会话? 删除 %1$d 个空会话?活动与归档会话会被保留。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 793ce2ba..a057be79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -877,6 +877,7 @@ Delete sessions older than the specified number of days. Days threshold Prune + Pruning old sessions… Empty Delete empty sessions? Delete %1$d empty session(s). Active and archived sessions are kept. diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/SessionDestructiveOpsSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/SessionDestructiveOpsSerializationTest.kt new file mode 100644 index 00000000..a84f616a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/SessionDestructiveOpsSerializationTest.kt @@ -0,0 +1,50 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * Wire-shape pins for the destructive session ops. The backend reads the + * profile off these request BODIES (`SessionPrune` / `BulkDeleteSessions`) — + * the `?profile=` query rewrite never reaches them — and its prune model is + * `older_than_days` (the old `days` key was silently ignored, leaving the + * server to prune its own 90-day default). + */ +class SessionDestructiveOpsSerializationTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun pruneRequest_sendsServerFieldNames() { + val encoded = json.encodeToString(PruneRequest(olderThanDays = 7, profile = "work")) + val body = json.parseToJsonElement(encoded).jsonObject + + assertEquals("7", body["older_than_days"]!!.jsonPrimitive.content) + assertEquals("work", body["profile"]!!.jsonPrimitive.content) + // The old key must be gone: the backend ignores it. + assertFalse(body.containsKey("days")) + } + + @Test + fun pruneRequest_omitsProfileWhenUnnamed() { + val encoded = json.encodeToString(PruneRequest(olderThanDays = 30)) + val body = json.parseToJsonElement(encoded).jsonObject + + assertEquals("30", body["older_than_days"]!!.jsonPrimitive.content) + assertFalse(body.containsKey("profile")) + } + + @Test + fun bulkDeleteRequest_carriesProfile() { + val encoded = json.encodeToString(BulkDeleteRequest(ids = listOf("a", "b"), profile = "work")) + val body = json.parseToJsonElement(encoded).jsonObject + + assertEquals("work", body["profile"]!!.jsonPrimitive.content) + assertEquals(listOf("a", "b"), body["ids"]!!.jsonArray.map { it.jsonPrimitive.content }) + } +}