app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 34++++++++++++++++++++++++++++++++++
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