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
This commit is contained in:
M57 authored and GitHub committed 2026-08-24 01:35:59 +03:00
1 parent 5fd732be9b
commit ece0288ca8
8 files changed
+342 -1

No files matched your search

@@ -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<String>? = 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<UpdateReceiptStep>? = null,
val skips: List<String>? = null,
val gatewayRestart: UpdateReceiptGatewayRestart? = null,
val fleet: List<UpdateReceiptFleetEntry>? = 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<String>? = null,
)
@@ -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<ActionResponse>
// ── 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<UpdateReceiptResponse>
// ── Admin: Portal ─────────────────────────────────────────────────
@GET("api/portal")
suspend fun getPortal(): Response<PortalResponse>
@@ -40,6 +40,11 @@ data class ActionProgressState(
val lines: List<String> = 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<String> = 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<String>) {
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()
@@ -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 = {
@@ -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<String> {
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() {
@@ -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<String> {
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<String>()
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
}
@@ -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()
@@ -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,
)
}
}