From ece0288ca8e451f52e4e115ad4611f79e3bbbcea Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:35:59 +0300 Subject: [PATCH] feat(update): surface backend update receipt in action popup (#958) (#964) Adopt GET /api/hermes/update/receipt and append its summary to the end of the hermes-update action progress popup after it finishes. Additive only: existing checkHermesUpdate + connection-retry polling is untouched. 404 (old backend) is handled gracefully. - add UpdateReceiptResponse data model matching backend schema - add getUpdateReceipt() to HermesApiService - ActionProgressController gains trailingLines + pushTrailingLines - ActionProgressDialog renders trailingLines after the log - SystemViewModel fetches receipt on action finish and formats it - extract pure formatUpdateReceiptLines for mockk-free testing --- .../data/model/UpdateReceiptResponse.kt | 81 +++++++++++++ .../data/remote/HermesApiService.kt | 7 ++ .../ui/common/ActionProgressController.kt | 15 +++ .../ui/common/ActionProgressDialog.kt | 21 ++++ .../ui/system/SystemViewModel.kt | 34 +++++- .../ui/system/UpdateReceiptFormatting.kt | 46 ++++++++ .../ui/common/ActionProgressControllerTest.kt | 30 +++++ .../ui/system/UpdateReceiptFormattingTest.kt | 109 ++++++++++++++++++ 8 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/UpdateReceiptResponse.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/system/UpdateReceiptFormatting.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/system/UpdateReceiptFormattingTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/UpdateReceiptResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/UpdateReceiptResponse.kt new file mode 100644 index 00000000..ace3c535 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/UpdateReceiptResponse.kt @@ -0,0 +1,81 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.Serializable + +/** + * Durable record of the most recent `hermes update` run, returned by + * `GET /api/hermes/update/receipt` (hermes-agent #91277). This is the + * authoritative outcome — it survives gateway restarts and log rotation, + * unlike heuristic connection-retry polling. + * + * All fields are nullable: the backend may emit a partial receipt, and the + * response is decoded with `ignoreUnknownKeys = true`, so future additions + * won't break parsing. + * + * The same data is also attached (flattened) to + * `GET /api/actions/hermes-update/status` under a `receipt` key. + */ +@Serializable +data class UpdateReceiptResponse( + val receipt: UpdateReceipt? = null, + val summary: UpdateReceiptSummary? = null, +) + +@Serializable +data class UpdateReceipt( + val schema: Int? = null, + val startedAt: String? = null, + val finishedAt: String? = null, + val argv: List? = null, + val pid: Int? = null, + /** `success` | `partial` | `running`. `running` means the update is still mid-flight. */ + val outcome: String? = null, + val preUpdate: UpdateReceiptVersion? = null, + val postUpdate: UpdateReceiptVersion? = null, + val steps: List? = null, + val skips: List? = null, + val gatewayRestart: UpdateReceiptGatewayRestart? = null, + val fleet: List? = null, +) + +@Serializable +data class UpdateReceiptVersion( + val sha: String? = null, + val version: String? = null, +) + +@Serializable +data class UpdateReceiptStep( + val name: String? = null, + val ok: Boolean? = null, + val detail: String? = null, + val at: String? = null, +) + +@Serializable +data class UpdateReceiptGatewayRestart( + val at: String? = null, + val ok: Boolean? = null, +) + +@Serializable +data class UpdateReceiptFleetEntry( + val profile: String? = null, + val pid: Int? = null, + val codeSha: String? = null, + val state: String? = null, +) + +/** + * Flattened highlights of [UpdateReceipt], returned alongside it by the + * endpoint. Mirrors the backend's `summary` object so the popup can show the + * essentials without walking the full receipt. + */ +@Serializable +data class UpdateReceiptSummary( + val outcome: String? = null, + val preSha: String? = null, + val postSha: String? = null, + val postVersion: String? = null, + val fleetStates: List? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 66b8fe99..40e13100 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -128,6 +128,7 @@ import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest import com.m57.hermescontrol.data.model.UpdateProfileModelRequest import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest import com.m57.hermescontrol.data.model.UpdateRawConfigRequest +import com.m57.hermescontrol.data.model.UpdateReceiptResponse import com.m57.hermescontrol.data.model.WebhookSubscription import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest import com.m57.hermescontrol.data.model.WebhooksResponse @@ -800,6 +801,12 @@ interface HermesApiService { @POST("api/hermes/update") suspend fun updateHermes(): Response + // ── Admin: Hermes update receipt (issue #958) ── + // Returns the durable record of the last `hermes update` run. 404 when no + // receipt exists yet (handled as "no info to show", not an error). + @GET("api/hermes/update/receipt") + suspend fun getUpdateReceipt(): Response + // ── Admin: Portal ───────────────────────────────────────────────── @GET("api/portal") suspend fun getPortal(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressController.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressController.kt index 7f2bd7c9..584bda97 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressController.kt @@ -40,6 +40,11 @@ data class ActionProgressState( val lines: List = emptyList(), val exitCode: Int? = null, val error: String? = null, + /** + * Host-appended lines shown after the log tail once the action finishes + * (e.g. a structured update receipt). Empty unless the host pushes them. + */ + val trailingLines: List = emptyList(), ) /** @@ -150,6 +155,16 @@ class ActionProgressController( _state.update { it.copy(phase = ActionProgressPhase.FAILED, error = error) } } + /** + * Append host-supplied lines after the log tail (e.g. an update receipt + * summary). Safe to call after the action has finished; only affects + * [ActionProgressState.trailingLines], never the poll loop. + */ + fun pushTrailingLines(lines: List) { + if (lines.isEmpty()) return + _state.update { it.copy(trailingLines = it.trailingLines + lines) } + } + /** Stop tracking and hide the dialog. Safe mid-run (action keeps going). */ fun dismiss() { pollJob?.cancel() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressDialog.kt index 581027f4..b440f0c3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/ActionProgressDialog.kt @@ -16,6 +16,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -169,6 +170,26 @@ fun ActionProgressDialog( color = statusColors.error, ) } + + // Host-appended trailing block (e.g. update receipt summary), + // shown after the log tail once the action has finished. + if (state.trailingLines.isNotEmpty()) { + HorizontalDivider( + modifier = Modifier.padding(vertical = spacing.sm), + color = MaterialTheme.colorScheme.outlineVariant, + ) + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + state.trailingLines.forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } }, confirmButton = { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index b9fb781e..5cf498ba 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -110,7 +110,7 @@ class SystemViewModel(application: Application) : val actionProgress = ActionProgressController( scope = viewModelScope, - onFinished = { loadAll() }, + onFinished = { status -> onActionFinished(status) }, ) private var actionPollingJob: Job? = null @@ -223,6 +223,38 @@ class SystemViewModel(application: Application) : } } + /** + * Fired when [actionProgress] finishes tracking a backend action. For the + * `hermes-update` action we additionally fetch the durable update receipt + * (issue #958) and append its summary to the end of the progress popup, + * then refresh the screen so the version row reflects the new build. Other + * actions just refresh. A 404 / missing receipt is silently skipped — the + * popup already shows success from the action status. + */ + private fun onActionFinished(status: ActionStatusResponse?) { + if (status?.name == "hermes-update") { + viewModelScope.launch { + val receipt = fetchUpdateReceiptLines() + if (receipt.isNotEmpty()) actionProgress.pushTrailingLines(receipt) + } + } + loadAll() + } + + /** + * Pull `GET /api/hermes/update/receipt` and render a compact summary block + * for the popup. Returns an empty list when the endpoint is unavailable + * (old backend, no receipt yet, or any error) so the caller appends nothing. + */ + private suspend fun fetchUpdateReceiptLines(): List { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getUpdateReceipt() } + } + val data = (result as? NetworkResult.Success)?.data ?: return emptyList() + return formatUpdateReceiptLines(data) + } + // ── Gateway actions ──────────────────────────────────────────────── fun startGateway() { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/UpdateReceiptFormatting.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/UpdateReceiptFormatting.kt new file mode 100644 index 00000000..2b8bb830 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/UpdateReceiptFormatting.kt @@ -0,0 +1,46 @@ +package com.m57.hermescontrol.ui.system + +import com.m57.hermescontrol.data.model.UpdateReceipt +import com.m57.hermescontrol.data.model.UpdateReceiptResponse +import com.m57.hermescontrol.data.model.UpdateReceiptSummary + +/** + * Render a compact, popup-friendly summary of an update receipt (issue #958). + * + * Returns an empty list when there is nothing to show (no receipt, old + * backend, or any malformed payload) so callers can skip appending entirely. + * + * Pure + [internal] so it is unit-testable without mocking the API layer. + */ +internal fun formatUpdateReceiptLines(receipt: UpdateReceiptResponse?): List { + if (receipt == null) return emptyList() + val summary: UpdateReceiptSummary? = receipt.summary + val full: UpdateReceipt? = receipt.receipt + if (summary == null && full == null) return emptyList() + + val lines = mutableListOf() + lines.add("Update receipt") + + val outcome = summary?.outcome ?: full?.outcome + if (outcome != null) lines.add("Outcome: $outcome") + + val pre = full?.preUpdate + val post = full?.postUpdate + if (pre != null || post != null) { + val from = pre?.version ?: pre?.sha?.take(8) ?: "?" + val to = post?.version ?: post?.sha?.take(8) ?: "?" + lines.add("Version: $from → $to") + } else if (summary?.postVersion != null) { + lines.add("Version: → ${summary.postVersion}") + } + + val fleet = full?.fleet + if (!fleet.isNullOrEmpty()) { + val states = fleet.map { "${it.profile ?: "?"}: ${it.state ?: "?"}" } + lines.add("Fleet: ${states.joinToString(", ")}") + } else if (summary != null && !summary.fleetStates.isNullOrEmpty()) { + lines.add("Fleet: ${summary.fleetStates.joinToString(", ")}") + } + + return lines +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/common/ActionProgressControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/common/ActionProgressControllerTest.kt index 72512fa8..cd3a0e96 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/common/ActionProgressControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/common/ActionProgressControllerTest.kt @@ -189,6 +189,36 @@ class ActionProgressControllerTest { assertEquals(callsAfterFirstPoll, calls) } + @Test + fun `pushTrailingLines appends after the log without touching the phase`() { + val controller = controller() + coEvery { mockApi.getActionStatus("hermes-update") } returns + Response.success( + ActionStatusResponse(name = "hermes-update", running = false, exit_code = 0), + ) + + controller.open() + controller.markStarted("hermes-update") + tick() + + assertEquals(ActionProgressPhase.SUCCEEDED, controller.state.value.phase) + assertTrue(controller.state.value.trailingLines.isEmpty()) + + controller.pushTrailingLines(listOf("Outcome: success", "Version: 0.20.4 → 0.20.5")) + + val state = controller.state.value + assertEquals(ActionProgressPhase.SUCCEEDED, state.phase) + assertEquals(listOf("Outcome: success", "Version: 0.20.4 → 0.20.5"), state.trailingLines) + } + + @Test + fun `pushTrailingLines ignores empty input`() { + val controller = controller() + controller.open() + controller.pushTrailingLines(emptyList()) + assertTrue(controller.state.value.trailingLines.isEmpty()) + } + @Test fun `onFinished is not called when dismissed mid-run`() { val controller = controller() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/system/UpdateReceiptFormattingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/system/UpdateReceiptFormattingTest.kt new file mode 100644 index 00000000..76177c8b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/system/UpdateReceiptFormattingTest.kt @@ -0,0 +1,109 @@ +package com.m57.hermescontrol.ui.system + +import com.m57.hermescontrol.data.model.UpdateReceipt +import com.m57.hermescontrol.data.model.UpdateReceiptFleetEntry +import com.m57.hermescontrol.data.model.UpdateReceiptResponse +import com.m57.hermescontrol.data.model.UpdateReceiptSummary +import com.m57.hermescontrol.data.model.UpdateReceiptVersion +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure-logic tests for [formatUpdateReceiptLines] (issue #958). Mockk cannot + * self-attach its agent in this sandbox, so these avoid the API layer entirely + * and exercise the formatting directly. + */ +class UpdateReceiptFormattingTest { + @Test + fun `null receipt yields empty list`() { + assertTrue(formatUpdateReceiptLines(null).isEmpty()) + } + + @Test + fun `empty receipt (no summary, no record) yields empty list`() { + assertTrue(formatUpdateReceiptLines(UpdateReceiptResponse()).isEmpty()) + } + + @Test + fun `full receipt renders outcome version and fleet`() { + val receipt = + UpdateReceiptResponse( + receipt = + UpdateReceipt( + outcome = "success", + preUpdate = UpdateReceiptVersion(version = "0.20.4", sha = "a".repeat(40)), + postUpdate = UpdateReceiptVersion(version = "0.20.5", sha = "b".repeat(40)), + fleet = + listOf( + UpdateReceiptFleetEntry(profile = "default", state = "current"), + UpdateReceiptFleetEntry(profile = "work", state = "stale"), + ), + ), + summary = + UpdateReceiptSummary( + outcome = "success", + postVersion = "0.20.5", + fleetStates = listOf("current", "stale"), + ), + ) + + val lines = formatUpdateReceiptLines(receipt) + assertEquals( + listOf( + "Update receipt", + "Outcome: success", + "Version: 0.20.4 → 0.20.5", + "Fleet: default: current, work: stale", + ), + lines, + ) + } + + @Test + fun `summary-only receipt falls back to postVersion and fleetStates`() { + val receipt = + UpdateReceiptResponse( + summary = + UpdateReceiptSummary( + outcome = "partial", + postVersion = "0.21.0", + fleetStates = listOf("current"), + ), + ) + + val lines = formatUpdateReceiptLines(receipt) + assertEquals( + listOf( + "Update receipt", + "Outcome: partial", + "Version: → 0.21.0", + "Fleet: current", + ), + lines, + ) + } + + @Test + fun `version falls back to short sha when version is missing`() { + val receipt = + UpdateReceiptResponse( + receipt = + UpdateReceipt( + outcome = "success", + preUpdate = UpdateReceiptVersion(sha = "abcdef1234567890deadbeef"), + postUpdate = UpdateReceiptVersion(sha = "11112222"), + ), + ) + + val lines = formatUpdateReceiptLines(receipt) + assertEquals( + listOf( + "Update receipt", + "Outcome: success", + "Version: abcdef12 → 11112222", + ), + lines, + ) + } +}