From 74dd9e6a006df8458902e9e1312c2c248bedfd42 Mon Sep 17 00:00:00 2001 From: vincent Date: Tue, 17 Feb 2026 01:30:44 -0500 Subject: [PATCH] Add Snowbird readiness and refresh guardrails from DWeb issue findings --- .../openarchive/db/FileUploadResult.kt | 5 +- .../services/snowbird/SnowbirdBridge.kt | 3 +- .../snowbird/SnowbirdFileListFragment.kt | 19 +++--- .../snowbird/SnowbirdFileRepository.kt | 14 +++++ .../snowbird/SnowbirdGroupListFragment.kt | 5 +- .../snowbird/SnowbirdGroupRepository.kt | 18 +++++- .../snowbird/SnowbirdGroupViewModel.kt | 4 +- .../snowbird/SnowbirdRepoRepository.kt | 14 +++++ .../snowbird/SnowbirdRepoViewModel.kt | 59 ++++++++++++++----- .../snowbird/service/SnowbirdService.kt | 40 ++++++++----- docs/SnowbirdLifecycle.md | 33 +++++++++++ 11 files changed, 169 insertions(+), 45 deletions(-) create mode 100644 docs/SnowbirdLifecycle.md diff --git a/app/src/main/java/net/opendasharchive/openarchive/db/FileUploadResult.kt b/app/src/main/java/net/opendasharchive/openarchive/db/FileUploadResult.kt index 6f296dbb9..68f6032f3 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/db/FileUploadResult.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/db/FileUploadResult.kt @@ -6,5 +6,6 @@ import kotlinx.serialization.Serializable @Serializable data class FileUploadResult ( var name: String, - @SerialName("updated_collection_hash") var updatedCollectionHash: String -) : SerializableMarker \ No newline at end of file + @SerialName("updated_collection_hash") var updatedCollectionHash: String, + @SerialName("file_hash") var fileHash: String? = null +) : SerializableMarker diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdBridge.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdBridge.kt index c495398ef..d83278c77 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdBridge.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdBridge.kt @@ -28,7 +28,8 @@ class SnowbirdBridge { @JvmStatic fun updateStatusFromRust(code: Int, message: String) { - instance?._status?.value = SnowbirdServiceStatus.fromCode(code) + // Preserve error context from Rust when available. + instance?._status?.value = SnowbirdServiceStatus.fromCode(code, message) } } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt index 300c5a369..6f3544909 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileListFragment.kt @@ -519,13 +519,16 @@ class SnowbirdFileListFragment : BaseSnowbirdFragment() { private fun onFileUploaded(result: FileUploadResult) { handleLoadingStatus(false) Timber.d("File successfully uploaded: $result") - SnowbirdFileItem( - name = result.name, - hash = result.updatedCollectionHash, - groupKey = groupKey, - repoKey = repoKey, - isDownloaded = true - ).save() + val uploadedHash = result.fileHash + if (!uploadedHash.isNullOrBlank()) { + SnowbirdFileItem( + name = result.name, + hash = uploadedHash, + groupKey = groupKey, + repoKey = repoKey, + isDownloaded = true + ).save() + } snowbirdFileViewModel.fetchFiles(groupKey, repoKey, forceRefresh = false) } @@ -564,4 +567,4 @@ class SnowbirdFileListFragment : BaseSnowbirdFragment() { const val RESULT_VAL_RAVEN_GROUP_KEY = "dweb_group_key" const val RESULT_VAL_RAVEN_REPO_KEY = "dweb_repo_key" } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileRepository.kt index 7e8c3cf78..dc4fa11fc 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileRepository.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileRepository.kt @@ -3,9 +3,12 @@ package net.opendasharchive.openarchive.services.snowbird import android.net.Uri import net.opendasharchive.openarchive.db.FileUploadResult import net.opendasharchive.openarchive.db.SnowbirdFileItem +import net.opendasharchive.openarchive.db.SnowbirdError import net.opendasharchive.openarchive.db.toFile import net.opendasharchive.openarchive.extensions.toSnowbirdError import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI +import net.opendasharchive.openarchive.services.snowbird.service.ServiceStatus +import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService interface ISnowbirdFileRepository { suspend fun fetchFiles(groupKey: String, repoKey: String, forceRefresh: Boolean = false): SnowbirdResult> @@ -15,6 +18,14 @@ interface ISnowbirdFileRepository { class SnowbirdFileRepository(val api: ISnowbirdAPI) : ISnowbirdFileRepository { + private fun ensureServerReadyForNetwork(): SnowbirdResult.Error? { + return if (SnowbirdService.getCurrentStatus() is ServiceStatus.Connected) { + null + } else { + SnowbirdResult.Error(SnowbirdError.GeneralError("DWeb server is not ready. Enable DWeb Server and wait for it to connect.")) + } + } + override suspend fun fetchFiles(groupKey: String, repoKey: String, forceRefresh: Boolean): SnowbirdResult> { return if (forceRefresh) { fetchFilesFromNetwork(groupKey, repoKey) @@ -29,6 +40,7 @@ class SnowbirdFileRepository(val api: ISnowbirdAPI) : ISnowbirdFileRepository { private suspend fun fetchFilesFromNetwork(groupKey: String, repoKey: String): SnowbirdResult> { return try { + ensureServerReadyForNetwork()?.let { return it } val response = api.fetchFiles(groupKey, repoKey) val files = response.files.map { it.toFile(groupKey = groupKey, repoKey = repoKey) } SnowbirdResult.Success(files) @@ -39,6 +51,7 @@ class SnowbirdFileRepository(val api: ISnowbirdAPI) : ISnowbirdFileRepository { override suspend fun downloadFile(groupKey: String, repoKey: String, filename: String): SnowbirdResult { return try { + ensureServerReadyForNetwork()?.let { return it } val response = api.downloadFile(groupKey, repoKey, filename) SnowbirdResult.Success(response) } catch (e: Exception) { @@ -48,6 +61,7 @@ class SnowbirdFileRepository(val api: ISnowbirdAPI) : ISnowbirdFileRepository { override suspend fun uploadFile(groupKey: String, repoKey: String, uri: Uri): SnowbirdResult { return try { + ensureServerReadyForNetwork()?.let { return it } val response = api.uploadFile(groupKey, repoKey, uri) SnowbirdResult.Success(response) } catch (e: Exception) { diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListFragment.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListFragment.kt index 8cac4bee3..6dcdedf6a 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListFragment.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupListFragment.kt @@ -51,7 +51,8 @@ class SnowbirdGroupListFragment : BaseSnowbirdFragment() { setupRecyclerView() initializeViewModelObservers() - snowbirdGroupViewModel.fetchGroups() + // Use network on first load so new memberships appear without restart. + snowbirdGroupViewModel.fetchGroups(forceRefresh = true) } private fun setupSwipeRefresh() { @@ -191,4 +192,4 @@ class SnowbirdGroupListFragment : BaseSnowbirdFragment() { override fun getToolbarTitle(): String { return "My Groups" } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupRepository.kt index 2958fdf30..96cee686d 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupRepository.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupRepository.kt @@ -4,8 +4,11 @@ import net.opendasharchive.openarchive.db.JoinGroupResponse import net.opendasharchive.openarchive.db.MembershipRequest import net.opendasharchive.openarchive.db.RequestName import net.opendasharchive.openarchive.db.SnowbirdGroup +import net.opendasharchive.openarchive.db.SnowbirdError import net.opendasharchive.openarchive.extensions.toSnowbirdError import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI +import net.opendasharchive.openarchive.services.snowbird.service.ServiceStatus +import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService interface ISnowbirdGroupRepository { suspend fun createGroup(groupName: String): SnowbirdResult @@ -18,8 +21,17 @@ class SnowbirdGroupRepository(val api: ISnowbirdAPI) : ISnowbirdGroupRepository private var lastFetchTime: Long = 0 private val cacheValidityPeriod: Long = 5 * 60 * 1000 + private fun ensureServerReadyForNetwork(): SnowbirdResult.Error? { + return if (SnowbirdService.getCurrentStatus() is ServiceStatus.Connected) { + null + } else { + SnowbirdResult.Error(SnowbirdError.GeneralError("DWeb server is not ready. Enable DWeb Server and wait for it to connect.")) + } + } + override suspend fun createGroup(groupName: String): SnowbirdResult { return try { + ensureServerReadyForNetwork()?.let { return it } val response = api.createGroup( RequestName(groupName) ) @@ -31,6 +43,7 @@ class SnowbirdGroupRepository(val api: ISnowbirdAPI) : ISnowbirdGroupRepository override suspend fun fetchGroup(groupKey: String): SnowbirdResult { return try { + ensureServerReadyForNetwork()?.let { return it } val response = api.fetchGroup(groupKey) SnowbirdResult.Success(response) } catch (e: Exception) { @@ -42,7 +55,7 @@ class SnowbirdGroupRepository(val api: ISnowbirdAPI) : ISnowbirdGroupRepository val currentTime = System.currentTimeMillis() val shouldFetchFromNetwork = forceRefresh || currentTime - lastFetchTime > cacheValidityPeriod - return if (forceRefresh) { + return if (shouldFetchFromNetwork) { fetchFromNetwork() } else { fetchFromCache() @@ -51,6 +64,7 @@ class SnowbirdGroupRepository(val api: ISnowbirdAPI) : ISnowbirdGroupRepository override suspend fun joinGroup(uriString: String): SnowbirdResult { return try { + ensureServerReadyForNetwork()?.let { return it } val response = api.joinGroup( MembershipRequest(uriString) ) @@ -63,7 +77,9 @@ class SnowbirdGroupRepository(val api: ISnowbirdAPI) : ISnowbirdGroupRepository private suspend fun fetchFromNetwork(): SnowbirdResult> { return try { + ensureServerReadyForNetwork()?.let { return it } val response = api.fetchGroups() + lastFetchTime = System.currentTimeMillis() SnowbirdResult.Success(response.groups) } catch (e: Exception) { SnowbirdResult.Error(e.toSnowbirdError()) diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupViewModel.kt index daf24bcee..6636da1b4 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupViewModel.kt @@ -59,7 +59,9 @@ class SnowbirdGroupViewModel( viewModelScope.launch { _groupState.value = GroupState.Loading try { - val result = processingTracker.trackProcessingWithTimeout(60_000, "fetch_groups") { + // Use longer timeout for refresh operations that may need to download collections from peers + val timeoutMs = if (forceRefresh) 120_000L else 60_000L + val result = processingTracker.trackProcessingWithTimeout(timeoutMs, "fetch_groups") { repository.fetchGroups(forceRefresh) } diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoRepository.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoRepository.kt index 0046f1e53..2bf55cef4 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoRepository.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoRepository.kt @@ -5,10 +5,13 @@ import kotlinx.serialization.Serializable import net.opendasharchive.openarchive.db.RefreshGroupResponse import net.opendasharchive.openarchive.db.RequestName import net.opendasharchive.openarchive.db.SnowbirdGroup +import net.opendasharchive.openarchive.db.SnowbirdError import net.opendasharchive.openarchive.db.SnowbirdRepo import net.opendasharchive.openarchive.db.toRepo import net.opendasharchive.openarchive.extensions.toSnowbirdError import net.opendasharchive.openarchive.services.snowbird.service.ISnowbirdAPI +import net.opendasharchive.openarchive.services.snowbird.service.ServiceStatus +import net.opendasharchive.openarchive.services.snowbird.service.SnowbirdService import timber.log.Timber interface ISnowbirdRepoRepository { @@ -19,10 +22,19 @@ interface ISnowbirdRepoRepository { class SnowbirdRepoRepository(val api: ISnowbirdAPI) : ISnowbirdRepoRepository { + private fun ensureServerReadyForNetwork(): SnowbirdResult.Error? { + return if (SnowbirdService.getCurrentStatus() is ServiceStatus.Connected) { + null + } else { + SnowbirdResult.Error(SnowbirdError.GeneralError("DWeb server is not ready. Enable DWeb Server and wait for it to connect.")) + } + } + override suspend fun createRepo(groupKey: String, repoName: String): SnowbirdResult { Timber.d("Creating repo: groupKey=$groupKey, repoName=$repoName") return try { + ensureServerReadyForNetwork()?.let { return it } val response = api.createRepo(groupKey, RequestName(repoName)) val repo = response.toRepo(groupKey) SnowbirdResult.Success(repo) @@ -41,6 +53,7 @@ class SnowbirdRepoRepository(val api: ISnowbirdAPI) : ISnowbirdRepoRepository { private suspend fun fetchFromNetwork(groupKey: String): SnowbirdResult> { return try { + ensureServerReadyForNetwork()?.let { return it } val response = api.fetchRepos(groupKey) val repoList = response.repos.map { it.toRepo(groupKey) } SnowbirdResult.Success(repoList) @@ -55,6 +68,7 @@ class SnowbirdRepoRepository(val api: ISnowbirdAPI) : ISnowbirdRepoRepository { override suspend fun refreshGroupContent(groupKey: String): SnowbirdResult { return try { + ensureServerReadyForNetwork()?.let { return it } val response = api.refreshGroupContent(groupKey) SnowbirdResult.Success(response) } catch (e: Exception) { diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoViewModel.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoViewModel.kt index 96c88042d..460659c81 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoViewModel.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoViewModel.kt @@ -90,20 +90,37 @@ class SnowbirdRepoViewModel( is SnowbirdResult.Success -> { AppLogger.i("Group content refreshed successfully") - //TODO: Save Repo List and Media List to DB - // Get existing repos for group + // Only persist refresh data for repos that belong to this group. + val allowedRepoIds: Set = run { + val fromNetwork = repository.fetchRepos(groupKey, forceRefresh = true) + when (fromNetwork) { + is SnowbirdResult.Success -> fromNetwork.value.map { it.key }.toSet() + is SnowbirdResult.Error -> { + AppLogger.w("Unable to fetch repos for scoping refresh; falling back to local DB scope: ${fromNetwork.error.friendlyMessage}") + SnowbirdRepo.getAllForGroupKey(groupKey).map { it.key }.toSet() + } + } + } + val existingRepos = SnowbirdRepo.getAllForGroupKey(groupKey) val existingReposMap = existingRepos.associateBy { it.key } + val repoErrors = mutableListOf() + result.value.refreshedRepos.forEach { repoData -> + if (allowedRepoIds.isNotEmpty() && !allowedRepoIds.contains(repoData.repoId)) { + AppLogger.e("Refresh returned repo outside group scope. groupKey=$groupKey repoId=${repoData.repoId} name=${repoData.name}") + return@forEach + } - // Log repo errors if any if (!repoData.error.isNullOrEmpty()) { - AppLogger.e("Error refreshing repo ${repoData.repoId}: ${repoData.error}") + val bucket = classifyRefreshError(repoData.error) + val msg = "Repo ${repoData.name} (${repoData.repoId}): $bucket — ${repoData.error}" + repoErrors.add(msg) + AppLogger.e(msg) } - // Update or create repo val snowbirdRepo = existingReposMap[repoData.repoId] ?: repoData.toRepo().apply { this.groupKey = groupKey } @@ -113,29 +130,34 @@ class SnowbirdRepoViewModel( permissions = if (repoData.canWrite) "READ_WRITE" else "READ_ONLY" }.save() - // Get existing files for this repo val existingFiles = SnowbirdFileItem.findBy(groupKey, repoData.repoId) val existingFilesMap = existingFiles.associateBy { it.name } - // Process all files (not just refreshed ones) repoData.allFiles.forEach { fileName -> val existingFile = existingFilesMap[fileName] if (existingFile == null) { - // Create new file if it doesn't exist SnowbirdFileItem( name = fileName, repoKey = repoData.repoId, groupKey = groupKey, ).save() - } else { - // Update existing file without overwriting with null - // Note: The refresh API doesn't provide file details, - // so we just maintain the existing file record } } } - _repoState.value = RepoState.RefreshGroupContentSuccess + + // Surface per-repo refresh failures while keeping persisted updates. + if (repoErrors.isNotEmpty()) { + val summary = repoErrors.take(8).joinToString("\n") + val suffix = if (repoErrors.size > 8) "\n… and ${repoErrors.size - 8} more" else "" + _repoState.value = RepoState.Error( + SnowbirdError.GeneralError( + "Some repositories failed to refresh:\n$summary$suffix\n\n(Types: DHT_DISCOVERY vs PEER_DOWNLOAD vs UNKNOWN)" + ) + ) + } else { + _repoState.value = RepoState.RefreshGroupContentSuccess + } fetchRepos(groupKey = groupKey) } } @@ -145,4 +167,13 @@ class SnowbirdRepoViewModel( } } } -} \ No newline at end of file + + private fun classifyRefreshError(message: String): String { + val m = message.lowercase() + return when { + "dht" in m || "repo root hash" in m -> "DHT_DISCOVERY" + "download from any peer" in m || "any peer" in m -> "PEER_DOWNLOAD" + else -> "UNKNOWN" + } + } +} diff --git a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt index 25f856615..53a25adce 100644 --- a/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt +++ b/app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt @@ -90,8 +90,18 @@ class SnowbirdService : Service() { createNotification("Snowbird Server is starting up.") ) - // Launch a coroutine to check & start + // Launch startup flow serviceScope.launch { + // Initialize bridge first to reduce startup race windows. + try { + withContext(Dispatchers.IO) { + SnowbirdBridge.getInstance().initialize() + } + } catch (e: Exception) { + Timber.e(e, "SnowbirdBridge.initialize() failed") + // Continue; startServer may still surface a clearer error. + } + val alreadyUp = isServerRunning() if (alreadyUp) { Timber.d("Snowbird server already running; skipping start()") @@ -142,17 +152,8 @@ class SnowbirdService : Service() { override fun onBind(intent: Intent?): IBinder? = null - /** - * Checks if a web server is available and responding with a 200 OK status. - * Throws exceptions on failure for better integration with retry mechanisms. - * - * @param url The URL to check - * @param timeout Optional timeout in milliseconds (default 5000ms) - * @throws ConnectException if the server refuses connection - * @throws SocketTimeoutException if the connection times out - * @throws IOException for other network-related errors - */ - private suspend fun checkServerAvailability(url: String, timeout: Int = 1000) { + /** Checks if a URL is reachable and returns 2xx. Throws on failure. */ + private suspend fun checkServerAvailability(url: String, timeout: Int = 8000) { withContext(Dispatchers.IO) { var connection: HttpURLConnection? = null try { @@ -164,7 +165,7 @@ class SnowbirdService : Service() { } when (connection.responseCode) { - HttpURLConnection.HTTP_OK -> return@withContext + in 200..299 -> return@withContext else -> throw IOException("Server returned ${connection.responseCode}") } } catch (e: Exception) { @@ -176,6 +177,12 @@ class SnowbirdService : Service() { } } + /** `/status` proves liveness; `/health/ready` proves backend initialization. */ + private suspend fun checkFullReadiness() { + checkServerAvailability("http://localhost:8080/status") + checkServerAvailability("http://localhost:8080/health/ready", timeout = 8000) + } + private fun createNotification(text: String, withSound: Boolean = false): Notification { val channelId = if (withSound) SaveApp.SNOWBIRD_SERVICE_CHANNEL_CHIME else SaveApp.SNOWBIRD_SERVICE_CHANNEL_SILENT @@ -208,7 +215,7 @@ class SnowbirdService : Service() { Timber.d("Starting polling") pollingJob?.cancel() // Cancel any existing polling - pollingJob = suspendToRetry { checkServerAvailability("http://localhost:8080/status") } + pollingJob = suspendToRetry { checkFullReadiness() } .retryWithScope( scope = serviceScope, config = RetryConfig( @@ -220,7 +227,8 @@ class SnowbirdService : Service() { shouldRetry = { error -> when (error) { is ConnectException, - is SocketTimeoutException -> true + is SocketTimeoutException, + is IOException -> true else -> false } @@ -306,4 +314,4 @@ sealed class ServiceStatus { data object Connecting : ServiceStatus() data object Connected : ServiceStatus() data class Failed(val error: Throwable) : ServiceStatus() -} \ No newline at end of file +} diff --git a/docs/SnowbirdLifecycle.md b/docs/SnowbirdLifecycle.md new file mode 100644 index 000000000..a248f4292 --- /dev/null +++ b/docs/SnowbirdLifecycle.md @@ -0,0 +1,33 @@ +# Snowbird Lifecycle (Android ↔ Local Rust Server) + +Quick reference for the startup/readiness and refresh issues seen in DWeb testing. + +## Startup flow + +1. `SnowbirdFragment` starts `SnowbirdService`. +2. `SnowbirdService` calls `SnowbirdBridge.initialize()` then `startServer(...)`. +3. Service marks connected only after both checks pass: + - `GET http://localhost:8080/status` + - `GET http://localhost:8080/health/ready` + +## Why this matters + +- `/status` means HTTP server is up. +- `/health/ready` means Veilid/Iroh/Blobs init is complete. +- Without the second check, UI can look connected while backend calls still fail. + +## Refresh guardrails + +- Filter refresh results to repos that belong to the selected group. +- Surface refresh failures by category: + - `DHT_DISCOVERY` (for repo root hash lookup failures) + - `PEER_DOWNLOAD` (for peer download failures) + - `UNKNOWN` + +## Relevant files + +- `app/src/main/java/net/opendasharchive/openarchive/services/snowbird/service/SnowbirdService.kt` +- `app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoViewModel.kt` +- `app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdGroupRepository.kt` +- `app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdRepoRepository.kt` +- `app/src/main/java/net/opendasharchive/openarchive/services/snowbird/SnowbirdFileRepository.kt`