app

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

commit fc02f1922a9ce99ee8efc34e81cca9a4e6fc6b01
parent 7756d758cfe6fb41580108c06dbdf533702f7c05
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 18:46:15 +0000

identity: repair native activation lifecycle

- synchronize activated identity metadata across the immutable snapshot
- enforce registry and active-identity value consistency at construction
- add an explicit production-native semantic UI lifecycle test lane
- prove generation, persistence, activation, sign-out, removal, and shutdown

Diffstat:
MMakefile | 7+++++--
Aapp/desktop/src/hostUiTest/kotlin/org/harvestcircle/hostui/IdentityLifecycleHostUiTest.kt | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt | 2++
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt | 23+++++++++++++++++++++++
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt | 7+++++++
Mcore/crates/harvestcircle_application/src/session.rs | 27++++++++++++++++++---------
Mcore/crates/harvestcircle_application/src/snapshot.rs | 3+--
Mcore/crates/harvestcircle_application/src/state_machine.rs | 15++++++++++++---
8 files changed, 242 insertions(+), 16 deletions(-)

diff --git a/Makefile b/Makefile @@ -17,10 +17,10 @@ else override BUILD_RUNNER := endif -.PHONY: help doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check acceptance-check signing-check _signing-check notarization-check _notarization-check release-check _release-check clean +.PHONY: help doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check host-ui-lifecycle-check acceptance-check signing-check _signing-check notarization-check _notarization-check release-check _release-check clean help: - @printf '%s\n' doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check acceptance-check signing-check notarization-check release-check clean + @printf '%s\n' doctor governed-doctor lock metadata build-logic-check build-logic-stability-check mode-check design-source-check design-goldens-update format format-fix lint test check governed-check build bindings dev-check dev run audit licenses foundation-check package host-package-check governed-package-check source-check governed-source-check package-check integration-check governed-integration-check host-ui-lifecycle-check acceptance-check signing-check notarization-check release-check clean design-source-check: doctor HARVESTCIRCLE_BUILD_MODE=$(BUILD_MODE) $(BUILD_RUNNER) $(CARGO) run --manifest-path $(XTASK_MANIFEST) --locked -- design-source-audit @@ -135,6 +135,9 @@ integration-check: build-logic-check check governed-integration-check: $(MAKE) --no-print-directory BUILD_MODE=governed integration-check +host-ui-lifecycle-check: doctor + $(BUILD_RUNNER) $(GRADLE) --no-daemon :app:desktop:hostUiLifecycleTest + acceptance-check: integration-check host-package-check signing-check: diff --git a/app/desktop/src/hostUiTest/kotlin/org/harvestcircle/hostui/IdentityLifecycleHostUiTest.kt b/app/desktop/src/hostUiTest/kotlin/org/harvestcircle/hostui/IdentityLifecycleHostUiTest.kt @@ -0,0 +1,174 @@ +package org.harvestcircle.hostui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.printToString +import androidx.compose.ui.test.v2.runComposeUiTest +import kotlinx.coroutines.runBlocking +import org.harvestcircle.application.ApplicationClock +import org.harvestcircle.application.ApplicationCommand +import org.harvestcircle.application.HarvestCircleApplicationWithDependencies +import org.harvestcircle.application.HarvestCirclePresenter +import org.harvestcircle.application.NativeHarvestCircleRuntime +import org.harvestcircle.application.OperationId +import org.harvestcircle.application.OperationIdSource +import org.harvestcircle.application.RequestContext +import org.harvestcircle.application.SecretClipboardController +import org.harvestcircle.application.TextClipboard +import org.harvestcircle.application.UnixSeconds +import org.harvestcircle.application.desktopRuntimeOpenConfiguration +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class IdentityLifecycleHostUiTest { + @Test + fun semanticUiGeneratesAddsActivatesAndRemovesIdentityThroughProductionNativeRuntime() = + runComposeUiTest { + val dataRoot = Files.createTempDirectory("harvestcircle-host-ui-").toRealPath() + val runtime = + NativeHarvestCircleRuntime.open( + desktopRuntimeOpenConfiguration( + developmentMode = true, + explicitDataDirectory = dataRoot.toString(), + configuredRelays = "", + ), + ) + val operationIds = HostOperationIds() + lateinit var presenter: HarvestCirclePresenter + var closeRequested by mutableStateOf(false) + var approvedExits = 0 + try { + setContent { + HarvestCircleApplicationWithDependencies( + closeRequested = closeRequested, + onExitApproved = { approvedExits += 1 }, + clipboardFactory = { scope -> + SecretClipboardController(scope, EmptyClipboard(), clearDelayMillis = 60_000) + }, + ) { scope -> + HarvestCirclePresenter( + runtime = runtime, + scope = scope, + clock = ApplicationClock { UnixSeconds(System.currentTimeMillis() / 1_000) }, + operationIds = operationIds, + ).also { presenter = it } + } + } + + waitForTag("bootstrap-welcome") + onNodeWithTag("bootstrap-create").performClick() + onNodeWithTag("generate-key").performClick() + waitForTag("generated-key-backup") + onNodeWithTag("acknowledge-key-backup").performClick() + waitForTag("saved-identity-list") + + val added = runtime.currentSnapshot() + assertEquals(1, added.identities.size) + val identityId = assertNotNull(added.selectedIdentityId) + + onNodeWithTag("activate-identity:${identityId.value}").performClick() + try { + waitForTag("foundation-today") + } catch (error: AssertionError) { + throw AssertionError( + "Activation state: ${presenter.state.value}", + error, + ) + } + val activated = runtime.currentSnapshot() + assertEquals(identityId, activated.activeIdentity?.identity?.id) + + onNodeWithTag("sidebar-Network").performClick() + waitForTag("network-overview") + onNodeWithTag("main-tab-identity").performClick() + onNodeWithTag("sign-out").performClick() + waitForTag("saved-identity-list") + onNodeWithTag("remove-identity:${identityId.value}").performClick() + waitForEnabled("overlay-confirm") + onNodeWithTag("overlay-confirm").performClick() + waitForTag("bootstrap-welcome") + + val removed = runtime.currentSnapshot() + assertTrue(removed.identities.isEmpty()) + assertEquals(null, removed.activeIdentity) + onAllNodesWithTag("identity-row:${identityId.value}").assertCountEquals(0) + + closeRequested = true + waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { approvedExits == 1 } + } finally { + cleanupRuntime(runtime, operationIds) + deleteTree(dataRoot) + } + } + + private fun androidx.compose.ui.test.ComposeUiTest.waitForTag(tag: String) { + try { + waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { + onAllNodesWithTag(tag).fetchSemanticsNodes().size == 1 + } + } catch (error: androidx.compose.ui.test.ComposeTimeoutException) { + throw AssertionError("Timed out waiting for semantic tag: $tag\n${onRoot().printToString()}", error) + } + onNodeWithTag(tag).assertIsDisplayed() + } + + private fun androidx.compose.ui.test.ComposeUiTest.waitForEnabled(tag: String) { + waitUntil(timeoutMillis = UI_TIMEOUT_MILLIS) { + runCatching { onNodeWithTag(tag).assertIsEnabled() }.isSuccess + } + } +} + +private fun cleanupRuntime( + runtime: NativeHarvestCircleRuntime, + operationIds: OperationIdSource, +) = runBlocking { + runCatching { runtime.execute(ApplicationCommand.SignOut) } + runCatching { + runtime.currentSnapshot().identities.forEach { identity -> + val request = runtime.requestIdentityRemoval(identity.id) + val revision = runtime.currentSnapshot().revision + runtime.execute( + ApplicationCommand.ConfirmIdentityRemoval( + request.requestId, + RequestContext(operationIds.next(), revision, 2_000UL), + ), + ) + } + } + runCatching { runtime.shutdown() } +} + +private class HostOperationIds : OperationIdSource { + private var next = 1 + + override fun next(): OperationId = OperationId.from("00000000-0000-7000-8001-${next++.toString().padStart(12, '0')}") +} + +private class EmptyClipboard : TextClipboard { + override fun readText(): String? = null + + override fun writeText(value: String) = Unit +} + +private fun deleteTree(root: Path) { + Files.walk(root).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach(Files::deleteIfExists) + } +} + +private const val UI_TIMEOUT_MILLIS = 15_000L diff --git a/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt b/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt @@ -212,6 +212,8 @@ class ConventionPluginSmokeTest { "verifyTestInventory", "compileIntegrationTestKotlin", "integrationTest", + "compileHostUiTestKotlin", + "hostUiLifecycleTest", ).forEach { taskName -> assertTrue(result.output.contains(taskName), result.output) } } diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt @@ -70,6 +70,7 @@ public class HarvestCircleDesktopAppPlugin : Plugin<Project> { configureKotlin(target) configureDependencies(target, catalog) configureIntegrationContract(target) + configureHostUiContract(target) configureTests(target) configureMetadata(target, catalog, appVersion, baseline["package.version"]) configureCompose(target, productCoordinates["desktop.main_class"]) @@ -166,6 +167,28 @@ public class HarvestCircleDesktopAppPlugin : Plugin<Project> { } } + private fun configureHostUiContract(target: Project) { + val sourceSets = target.extensions.getByType(SourceSetContainer::class.java) + val hostUi = sourceSets.maybeCreate("hostUiTest") + val main = sourceSets.getByName("main") + hostUi.compileClasspath += main.output + hostUi.runtimeClasspath += main.output + target.configurations.named("hostUiTestImplementation") { + it.extendsFrom(target.configurations.getByName("testImplementation")) + } + target.configurations.named("hostUiTestRuntimeOnly") { + it.extendsFrom(target.configurations.getByName("testRuntimeOnly")) + } + target.tasks.register("hostUiLifecycleTest", Test::class.java) { task -> + task.description = "Runs the explicit host-native desktop identity lifecycle UI suite." + task.group = "verification" + task.testClassesDirs = hostUi.output.classesDirs + task.classpath = hostUi.runtimeClasspath + task.dependsOn("ktlintHostUiTestSourceSetCheck", "detektHostUiTest") + task.shouldRunAfter(target.tasks.named("integrationTest")) + } + } + private fun configureTests(target: Project) { val sourceRoot = target.providers.gradleProperty("testInventoryRoot").orElse("src/test/kotlin") val inventory = diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleRustFfiPlugin.kt @@ -237,6 +237,13 @@ public class HarvestCircleRustFfiPlugin : Plugin<Project> { }, ) } + target.tasks.named("compileHostUiTestKotlin", KotlinCompile::class.java) { task -> + task.compilerOptions.freeCompilerArgs.add( + target.layout.buildDirectory.dir("classes/kotlin/main").map { output -> + "-Xfriend-paths=${output.asFile.absolutePath}" + }, + ) + } target.tasks.named("integrationTest", Test::class.java) { task -> task.dependsOn(verifyTestIsolation) task.systemProperty("jna.library.path", testBridgeLibraryFile.parentFile.absolutePath) diff --git a/core/crates/harvestcircle_application/src/session.rs b/core/crates/harvestcircle_application/src/session.rs @@ -165,15 +165,16 @@ mod tests { .expect("second") .identity() .public_key(); - core.activate_identity( - first, - &identities, - &identities, - &profiles, - &secrets, - &FixedClock, - ) - .expect("activate first"); + let activated = core + .activate_identity( + first, + &identities, + &identities, + &profiles, + &secrets, + &FixedClock, + ) + .expect("activate first"); assert_eq!(core.snapshot().session(), SessionState::Active); assert_eq!( core.snapshot() @@ -181,6 +182,14 @@ mod tests { .map(|active| active.identity().public_key()), Some(first) ); + let registered = activated + .identities() + .iter() + .find(|identity| identity.public_key() == first) + .expect("activated identity remains registered"); + let active = activated.active_identity().expect("active identity"); + assert_eq!(registered, active.identity()); + assert_eq!(registered.last_used_at(), Some(FixedClock.now())); secrets.delete(second).expect("remove second credential"); let error = core diff --git a/core/crates/harvestcircle_application/src/snapshot.rs b/core/crates/harvestcircle_application/src/snapshot.rs @@ -275,8 +275,7 @@ fn validate_snapshot( if unique_identities.len() != identities.len() || (identities.is_empty() != selected_identity.is_none()) || selected_identity.is_some_and(|key| !unique_identities.contains(&key)) - || active_identity - .is_some_and(|active| !unique_identities.contains(&active.identity().public_key())) + || active_identity.is_some_and(|active| !identities.contains(active.identity())) || (matches!(session, SessionState::Active) && active_identity.is_none()) || (matches!(session, SessionState::SignedOut) && active_identity.is_some()) { diff --git a/core/crates/harvestcircle_application/src/state_machine.rs b/core/crates/harvestcircle_application/src/state_machine.rs @@ -248,14 +248,23 @@ impl StateMachine { return Err(invalid_application_state()); } let target = *target; - self.pending_activation = None; - self.copy_ready( + let mut identities = self.snapshot.identities().to_vec(); + let registered = identities + .iter_mut() + .find(|identity| identity.public_key() == target) + .ok_or_else(identity_not_found)?; + *registered = active_identity.identity().clone(); + let next = AppSnapshot::ready( revision, + self.snapshot.relay_configuration().clone(), + identities, Some(target), SessionState::Active, Some(active_identity), None, - ) + )?; + self.pending_activation = None; + Ok(next) } fn activation_failed(