From c35f84a3739cdf9d67e25d8b26979d6a582647f8 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Tue, 1 Sep 2026 10:09:29 -0400 Subject: [PATCH] Withhold a component's freshness while it is being built Nothing re-reads the manifest during a run, so the "out of date" note beside a running progress bar is the answer from before the button was pressed -- shown for the length of a build, next to the work that is making it wrong. The card now reads a busy component's freshness as unknown, in the one place the list of components is built for the cards, so the row's note and the sibling-mismatch warning both go quiet. Same condition as the one that disables the Update button. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 15 ++ .../com/example/devupdater/UpdaterScreen.kt | 182 ++++++++++-------- 2 files changed, 116 insertions(+), 81 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2c90db0..4eb9cd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -494,6 +494,21 @@ mutable at runtime from the phone. the Retry that acts on it, in the one place that reports that component's failures whether they came from a build, a download or a service action. +- **A card being worked on has no freshness, rather than the one from + before the press.** Nothing re-reads the manifest during a run -- the + entry was fetched before the button was pressed and read again only + once the run is over -- so "out of date" drawn beside the bar that is + making it current is last minute's answer wearing this minute's + clothes. `UpdaterScreen` therefore maps a busy component's `freshness` + to `unknown` in the one place the component list is built for the + cards, which is what makes both readers of it -- the row's own note and + `MismatchedPairNote` -- go quiet without either having to know why. The + condition is deliberately the same pair that disables the Update button + (`projectState.busy || componentState.busy`): what cannot be acted on + is exactly what cannot be measured just now. It comes back the instant + the run ends, still saying "out of date" if the build failed, because + by then the entry has been read again -- withheld is not the same as + cleared. - **A project's own `.dev-updater.ron` is a request, never an instruction.** It only runs once accepted from the phone, which copies it into `config.ron`; `AppEntry::pending_declaration` is the whole gate. diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt index cd3caf5..18d69fc 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt @@ -1580,87 +1580,107 @@ private fun AppCard( // order is the *build* order -- dev-updater builds its // server before its APK on purpose -- and that stays as // it is; this sort is stable, so anything else keeps it. - entry.components - .sortedBy { it.isServer } - .forEach { component -> - val installed = installedTimes[component.name] - val installedSize = installedSizes[component.name] - val chosenVariantPath = chosenVariants[component.name] - // This component's own, so nothing below can - // reach for a sibling's by accident. - val componentState = componentStates[component.name] - val upToDate = - component.apk?.let { - isUpToDate(it, installed, chosenVariantPath) - } == true - ComponentCard( - entryKey = entry.key, - component = component, - packageName = component.apk?.packageName, - // What the download would cost, and what it - // replaces. An APK's size sits where a - // server's state does: the one thing worth - // knowing about it besides its name. - sizeText = - component.apk - ?.takeIf { it.built } - ?.let { apk -> - when { - !upToDate && installedSize != null -> - "${formatSize(installedSize)} \u2192 " + - formatSize(apk.size) - else -> formatSize(apk.size) - } - }, - chosenVariantPath = chosenVariantPath, - onSelectVariant = { onSelectVariant(component.name, it) }, - // This app reaches the server through this server. - // Stopping or uninstalling it is the one action - // here that cannot be undone from the phone. - isOwnServer = entry.builtIn, - build = - componentBuild( - projectState, - componentState, - component.name, - ), - // Being worked on right now, which the - // component's own slice of the build says - // directly rather than being inferred from a - // project-wide phase name. - working = - componentBuild(projectState, componentState, component.name) - ?.running == true, - busy = serviceBusy == component.name, - state = componentState, - onAction = { action, purge -> - onServiceAction(component.name, action, purge) - }, - controls = { - if (!component.isServer) { - // Said before the button, because it - // qualifies what pressing it gets you. - MismatchedPairNote( - self = component, - others = entry.components, - ) - // The button that asked for it, then - // how far along it is: a bar reports on - // the control above it. - UpdateButton( - built = component.apk?.built == true, - needsBuild = entry.needsBuild, - installed = installed != null, - upToDate = upToDate, - state = componentState, - projectState = projectState, - onUpdate = { onUpdate(entry, component.name) }, - ) - ApkProgress(componentState) - } - }, - ) - } + // + // Freshness is withheld from a component being worked + // on, because it is the one thing on the card that the + // work is *about* and nothing re-reads it while the + // work runs: the entry was fetched before the button + // was pressed and is read again only once the run is + // over. So "out of date" beside the bar that is making + // it current is not a measurement, it is the answer + // from before the press -- and unknown is what this + // card already says when it has no reading, so both + // readers of it (the row's own note, and the sibling + // warning) go quiet without either having to know why. + // Deliberately the same pair of conditions that + // disables the Update button: what cannot be acted on + // is exactly what cannot be measured just now. + val components = + entry.components + .sortedBy { it.isServer } + .map { component -> + if (projectState.busy || componentStates[component.name].busy) + component.copy(freshness = "unknown") + else component + } + components.forEach { component -> + val installed = installedTimes[component.name] + val installedSize = installedSizes[component.name] + val chosenVariantPath = chosenVariants[component.name] + // This component's own, so nothing below can + // reach for a sibling's by accident. + val componentState = componentStates[component.name] + val upToDate = + component.apk?.let { isUpToDate(it, installed, chosenVariantPath) } == + true + ComponentCard( + entryKey = entry.key, + component = component, + packageName = component.apk?.packageName, + // What the download would cost, and what it + // replaces. An APK's size sits where a + // server's state does: the one thing worth + // knowing about it besides its name. + sizeText = + component.apk + ?.takeIf { it.built } + ?.let { apk -> + when { + !upToDate && installedSize != null -> + "${formatSize(installedSize)} \u2192 " + + formatSize(apk.size) + else -> formatSize(apk.size) + } + }, + chosenVariantPath = chosenVariantPath, + onSelectVariant = { onSelectVariant(component.name, it) }, + // This app reaches the server through this server. + // Stopping or uninstalling it is the one action + // here that cannot be undone from the phone. + isOwnServer = entry.builtIn, + build = + componentBuild( + projectState, + componentState, + component.name, + ), + // Being worked on right now, which the + // component's own slice of the build says + // directly rather than being inferred from a + // project-wide phase name. + working = + componentBuild(projectState, componentState, component.name) + ?.running == true, + busy = serviceBusy == component.name, + state = componentState, + onAction = { action, purge -> + onServiceAction(component.name, action, purge) + }, + controls = { + if (!component.isServer) { + // Said before the button, because it + // qualifies what pressing it gets you. + MismatchedPairNote( + self = component, + others = components, + ) + // The button that asked for it, then + // how far along it is: a bar reports on + // the control above it. + UpdateButton( + built = component.apk?.built == true, + needsBuild = entry.needsBuild, + installed = installed != null, + upToDate = upToDate, + state = componentState, + projectState = projectState, + onUpdate = { onUpdate(entry, component.name) }, + ) + ApkProgress(componentState) + } + }, + ) + } } } // What there is to read before pressing anything, directly