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:
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.UpdateProfileModelRequest
|
||||||
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
|
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
|
||||||
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
|
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.WebhookSubscription
|
||||||
import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest
|
import com.m57.hermescontrol.data.model.WebhookToggleSubscriptionRequest
|
||||||
import com.m57.hermescontrol.data.model.WebhooksResponse
|
import com.m57.hermescontrol.data.model.WebhooksResponse
|
||||||
@@ -800,6 +801,12 @@ interface HermesApiService {
|
|||||||
@POST("api/hermes/update")
|
@POST("api/hermes/update")
|
||||||
suspend fun updateHermes(): Response<ActionResponse>
|
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 ─────────────────────────────────────────────────
|
// ── Admin: Portal ─────────────────────────────────────────────────
|
||||||
@GET("api/portal")
|
@GET("api/portal")
|
||||||
suspend fun getPortal(): Response<PortalResponse>
|
suspend fun getPortal(): Response<PortalResponse>
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ data class ActionProgressState(
|
|||||||
val lines: List<String> = emptyList(),
|
val lines: List<String> = emptyList(),
|
||||||
val exitCode: Int? = null,
|
val exitCode: Int? = null,
|
||||||
val error: String? = 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) }
|
_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). */
|
/** Stop tracking and hide the dialog. Safe mid-run (action keeps going). */
|
||||||
fun dismiss() {
|
fun dismiss() {
|
||||||
pollJob?.cancel()
|
pollJob?.cancel()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -169,6 +170,26 @@ fun ActionProgressDialog(
|
|||||||
color = statusColors.error,
|
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 = {
|
confirmButton = {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class SystemViewModel(application: Application) :
|
|||||||
val actionProgress =
|
val actionProgress =
|
||||||
ActionProgressController(
|
ActionProgressController(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
onFinished = { loadAll() },
|
onFinished = { status -> onActionFinished(status) },
|
||||||
)
|
)
|
||||||
|
|
||||||
private var actionPollingJob: Job? = null
|
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 ────────────────────────────────────────────────
|
// ── Gateway actions ────────────────────────────────────────────────
|
||||||
|
|
||||||
fun startGateway() {
|
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)
|
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
|
@Test
|
||||||
fun `onFinished is not called when dismissed mid-run`() {
|
fun `onFinished is not called when dismissed mid-run`() {
|
||||||
val controller = controller()
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user