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
}