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:
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(