Each component builds on its own, and stops reporting when it is done

A project's components were decoupled everywhere except the one place it
showed: there was a single build slot per project, and a single card state
in the app keyed by project alone. So pressing Update on one client of a
two-client project disabled the other client's button for the length of a
build it shares nothing with, drew this one's progress bar and download
percentage under the other's row, and -- had the button been pressable --
would have been a silent no-op on the server, since a second request while
one was running returned without starting anything.

The slot is now per component. `Inner` has no `building` flag; a
component's own `ComponentRun` with an open `step` is the answer, and
`claim` writes that entry synchronously under the lock the route answers
from, so nothing can read a just-claimed component as idle -- which the
phone would take for a build that had already finished. A failure is
recorded against the component whose command it was rather than in the
project's one error slot, which two components building at once cannot
share.

A pull stays exclusive with everything, because there is one checkout and
it rewrites the files every component builds from. Releasing it and
claiming what it decided to build happen under one lock: a phone polling in
the gap would find a project neither pulling nor building and call the run
over.

The app mirrors the split -- `ProjectState` for the pull and the
project-wide Rebuild, `ComponentState` keyed by component for everything
one component is asked to do. Two hierarchies rather than one keyed by a
pair, so a download has nowhere project-wide to be stored. A component's
failure is drawn in its own row beside the Retry that acts on it, which is
also where a failed service action now reports.

And a finished component shows nothing at all: the elapsed times are gone
from both halves of the wire, and its button simply goes back to being
pressable. A bar, a count and a last line all describe something happening
now, and left up they sit there looking live next to a sibling that
genuinely is.

Verified on the emulator against test-projects/two-clients, which exists
for this: while `tablet` built, its row alone carried the bar and its
button alone was disabled, `phone` stayed pressable and silent, and both
returned to normal with no timing left behind.
This commit is contained in:
iris committed 2026-09-01 03:09:33 -04:00
1 parent 4641b9ec9b
commit 90082bd286
6 files changed
+862 -318

No files matched your search

+18
View File
@@ -493,6 +493,23 @@ struct ManifestApp {
/// one flag for the list, so a card says whether *it* is the one still
/// being worked out.
check_pending: bool,
/// The build running for this project right now, if one is, in the
/// same shape `/status` answers.
///
/// Here so that a build survives leaving the app. The run itself never
/// stops -- it owns its own `Arc<BuildState>` and outlives the request
/// that started it -- but the phone's record of it lives only in the
/// composition, so backgrounding tears down the polling loop and the
/// card state together. Without this the list it comes back to cannot
/// say a build is still going, and the card reads as one that was
/// killed: it offers Update again, and pressing it does nothing,
/// because there is already a run in this project's one build slot.
///
/// Read with [`crate::build_state::BuildState::running`] rather than
/// `status`, which also answers `stale` and walks every component's
/// directory to do it -- this is on the manifest path.
#[serde(skip_serializing_if = "Option::is_none")]
build: Option<crate::build_state::RunningBuild>,
/// What this project produces, in the order it is built. One entry is
/// the ordinary case and the card shows it inline; more than one is
/// what the phone draws as a nested list.
@@ -656,6 +673,7 @@ async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp
.build
.as_ref()
.is_some_and(|build| build.has_command()),
build: entry.build.as_ref().and_then(|build| build.running()),
git_ipv4: entry.git_ipv4,
built_in: entry.built_in,
pending_declaration: pending