commit 8ec6d269209aabd864469d12a3d4e8159959ff66
parent cd6b276b28c5d1789674fa9139781583c61abaf7
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 23:47:52 +0000
application: make command admission explicit
- represent running accepted busy closed and failed command states
- surface typed request correlations and retryability
- reject concurrent and unavailable intents without silent loss
- preserve import recovery and removal presentation on rejection
Diffstat:
2 files changed, 121 insertions(+), 13 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt
@@ -17,6 +17,16 @@ enum class StudioRoute {
CLOSED,
}
+enum class CommandStatus {
+ IDLE,
+ RUNNING,
+ ACCEPTED,
+ REJECTED_BUSY,
+ REJECTED_CLOSED,
+ FAILED_RETRYABLE,
+ FAILED_TERMINAL,
+}
+
data class StudioStoreState(
val snapshot: AppSnapshotDto,
val route: StudioRoute = snapshot.toStudioRoute(),
@@ -25,6 +35,8 @@ data class StudioStoreState(
val pendingRemovalPublicKeyHex: String? = null,
val accountChooserVisible: Boolean = false,
val busy: Boolean = false,
+ val commandStatus: CommandStatus = CommandStatus.IDLE,
+ val lastCommandRequestId: String? = null,
val problem: String? = null,
)
@@ -82,7 +94,10 @@ class StudioAppStore(
}
fun acknowledgeGeneratedKeyBackup() {
- val recovery = pendingGeneratedRecovery ?: return
+ val recovery = pendingGeneratedRecovery ?: run {
+ rejectUnavailableIntent("Generated-key recovery is not available.")
+ return
+ }
pendingGeneratedRecovery = null
runSnapshotCommand {
try {
@@ -97,7 +112,7 @@ class StudioAppStore(
}
fun importSecretKey() {
- if (command?.isActive == true) return
+ if (rejectIfUnavailable()) return
val input = mutableState.value.importDraft.encodeToByteArray()
mutableState.value = mutableState.value.copy(importDraft = "")
runTypedCommand(StudioCommand.ImportAccount(input))
@@ -147,13 +162,15 @@ class StudioAppStore(
fun cancelAccountRemoval() {
pendingRemoval?.close()
- pendingGeneratedRecovery?.close()
pendingRemoval = null
mutableState.value = mutableState.value.copy(pendingRemovalPublicKeyHex = null)
}
fun confirmAccountRemoval() {
- val ticket = pendingRemoval ?: return
+ val ticket = pendingRemoval ?: run {
+ rejectUnavailableIntent("Account removal confirmation is not available.")
+ return
+ }
pendingRemoval = null
runSnapshotCommand {
try {
@@ -180,27 +197,75 @@ class StudioAppStore(
when (val result = gateway.execute(command)) {
is StudioCommandResult.Accepted -> {
acceptSnapshot(result.receipt.snapshot)
+ mutableState.value = mutableState.value.copy(
+ commandStatus = CommandStatus.ACCEPTED,
+ lastCommandRequestId = result.receipt.requestId,
+ )
if (hideChooser) {
mutableState.value = mutableState.value.copy(accountChooserVisible = false)
}
}
is StudioCommandResult.Rejected -> {
- mutableState.value = mutableState.value.copy(problem = result.failure.safeMessage)
+ mutableState.value = mutableState.value.copy(
+ commandStatus = if (result.failure.retryable) {
+ CommandStatus.FAILED_RETRYABLE
+ } else {
+ CommandStatus.FAILED_TERMINAL
+ },
+ lastCommandRequestId = result.failure.correlationId,
+ problem = result.failure.safeMessage,
+ )
}
}
}
}
private fun launchCommand(operation: suspend () -> Unit) {
- if (closed || command?.isActive == true) return
- mutableState.value = mutableState.value.copy(busy = true, problem = null)
+ if (rejectIfUnavailable()) return
+ mutableState.value = mutableState.value.copy(
+ busy = true,
+ commandStatus = CommandStatus.RUNNING,
+ problem = null,
+ )
command = scope.launch {
- runCatching { operation() }
- .onFailure(::acceptFailure)
- mutableState.value = mutableState.value.copy(busy = false)
+ try {
+ operation()
+ if (mutableState.value.commandStatus == CommandStatus.RUNNING) {
+ mutableState.value = mutableState.value.copy(commandStatus = CommandStatus.ACCEPTED)
+ }
+ } catch (error: Throwable) {
+ acceptFailure(error)
+ } finally {
+ mutableState.value = mutableState.value.copy(busy = false)
+ }
}
}
+ private fun rejectIfUnavailable(): Boolean {
+ if (closed) {
+ mutableState.value = mutableState.value.copy(
+ commandStatus = CommandStatus.REJECTED_CLOSED,
+ problem = "The application runtime is closed.",
+ )
+ return true
+ }
+ if (command?.isActive == true) {
+ mutableState.value = mutableState.value.copy(
+ commandStatus = CommandStatus.REJECTED_BUSY,
+ problem = "The application is busy. Try again.",
+ )
+ return true
+ }
+ return false
+ }
+
+ private fun rejectUnavailableIntent(message: String) {
+ mutableState.value = mutableState.value.copy(
+ commandStatus = if (closed) CommandStatus.REJECTED_CLOSED else CommandStatus.FAILED_TERMINAL,
+ problem = message,
+ )
+ }
+
private fun acceptSnapshot(snapshot: AppSnapshotDto) {
if (snapshot.revision >= mutableState.value.snapshot.revision) {
mutableState.value = mutableState.value.copy(
@@ -211,9 +276,17 @@ class StudioAppStore(
}
private fun acceptFailure(error: Throwable) {
- val message = (error as? StudioException.Failure)?.safeMessage
- ?: "The application command failed."
- mutableState.value = mutableState.value.copy(busy = false, problem = message)
+ val native = error as? StudioException.Failure
+ mutableState.value = mutableState.value.copy(
+ busy = false,
+ commandStatus = if (native?.retryable == true) {
+ CommandStatus.FAILED_RETRYABLE
+ } else {
+ CommandStatus.FAILED_TERMINAL
+ },
+ lastCommandRequestId = native?.correlationId,
+ problem = native?.safeMessage ?: "The application command failed.",
+ )
}
override fun close() {
@@ -221,6 +294,7 @@ class StudioAppStore(
closed = true
command?.cancel()
pendingRemoval?.close()
+ pendingGeneratedRecovery?.close()
subscription?.close()
generatedRecovery.close()
gateway.close()
diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt
@@ -9,6 +9,9 @@ import org.radroots.studio.ffi.AppSnapshotDto
import org.radroots.studio.ffi.KeyAvailabilityDto
import org.radroots.studio.ffi.SessionStateDto
import org.radroots.studio.ffi.SignerKindDto
+import org.radroots.studio.ffi.WireErrorCategory
+import org.radroots.studio.ffi.WireErrorCode
+import org.radroots.studio.ffi.WireRecoveryAction
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -91,6 +94,7 @@ class StudioAppStoreTest {
val store = StudioAppStore(gateway, this)
store.signOut()
+ assertEquals(CommandStatus.REJECTED_BUSY, store.state.value.commandStatus)
advanceUntilIdle()
assertEquals(0, gateway.signOutCalls)
@@ -101,6 +105,31 @@ class StudioAppStoreTest {
}
@Test
+ fun `projects retryable command rejection without dropping intent`() = runTest {
+ val gateway = FakeStudioCoreGateway(snapshot(0UL))
+ val store = StudioAppStore(gateway, this)
+ advanceUntilIdle()
+ gateway.nextCommandResult = StudioCommandResult.Rejected(
+ StudioCommandFailure(
+ WireErrorCode.STORAGE_UNAVAILABLE,
+ WireErrorCategory.STORAGE,
+ retryable = true,
+ WireRecoveryAction.RETRY,
+ "request-retry",
+ "Storage is temporarily unavailable.",
+ ),
+ )
+
+ store.signOut()
+ advanceUntilIdle()
+
+ assertEquals(CommandStatus.FAILED_RETRYABLE, store.state.value.commandStatus)
+ assertEquals("request-retry", store.state.value.lastCommandRequestId)
+ assertEquals("Storage is temporarily unavailable.", store.state.value.problem)
+ store.close()
+ }
+
+ @Test
fun `clears imported secret draft as soon as command is accepted`() = runTest {
val gateway = FakeStudioCoreGateway(snapshot(0UL))
val store = StudioAppStore(gateway, this)
@@ -129,6 +158,7 @@ private class FakeStudioCoreGateway(
var lastImportBuffer: ByteArray? = null
var failRemovalConfirmation = false
var lastRemovalTicket: FakeRemovalTicket? = null
+ var nextCommandResult: StudioCommandResult? = null
override fun snapshot(): AppSnapshotDto = current
@@ -138,6 +168,10 @@ private class FakeStudioCoreGateway(
}
override suspend fun execute(command: StudioCommand): StudioCommandResult {
+ nextCommandResult?.let {
+ nextCommandResult = null
+ return it
+ }
when (command) {
is StudioCommand.ImportAccount -> {
lastImportBuffer = command.bytes