Build a component this server has never built

"Not built here" was the one state nothing could clear. With no recorded
commit a component was not stale, so Update built nothing, so no build
ever wrote a record -- the card said it, and Pull, Update and the
project's own button all did nothing about it, for ever. Worst for a
server component, which the output check beside this one skips by
design, having no APK to look for: that is why ai-app's backend sat
there saying it after an Update that otherwise worked.

Measured on a two-component fixture rather than argued. Before:
/prepare answers `components: []` and the freshness is unchanged
afterwards. After: the same call reports `step: "building"`, the card
reads `current`, and `builtFrom` is on disk.

So `nothing_built` asks the record as well as the output. The cost is
one rebuild per component on a machine whose records were lost, which
was the objection when this was written the other way round -- and is
the right price now that the state is visible on the card. It is
self-clearing: the first build writes the record and the question is
never asked again.

That leaves NotBuiltHere meaning exactly one thing, a component with no
build command, where nothing was ever going to measure anything and no
button would change it. Quiet and unflagged, as against NeverBuilt,
which is flagged and has a Build beside it. One word had been covering
both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-02 23:49:36 -04:00
1 parent ce1f02b98f
commit 663626345a
2 files changed
+137 -26

No files matched your search

+28
View File
@@ -587,6 +587,34 @@ mutable at runtime from the phone.
differ in nothing but the label and the old one said "Pulling and differ in nothing but the label and the old one said "Pulling and
building" for all three. building" for all three.
- **"This server has never built it" is something to build, not
something to sit on** -- and getting that backwards made it the one
state nothing could clear. `component_is_stale` treated a missing
`built_from` as no evidence of staleness, so Update built nothing, so
no build ever wrote the record: a card reading "not built here" with
Pull, Update and the project's own button all doing nothing about it,
for ever. Worst for a `Server`, which the output check beside it
deliberately skips (it has no APK to look for), so nothing else could
rescue it. Measured on a two-component fixture rather than argued:
`/prepare` answered `components: []` and the freshness was unchanged
afterwards; with the fix the same call reports `step: "building"` and
the card reads `current`.
So `nothing_built` now asks the record as well as the output, and the
cost is one rebuild per component on a machine whose records were lost
-- which was the objection when it was written the other way, and is
the right price now that the state is *visible*. It is self-clearing:
the first build writes the record and the question is never asked
again.
Which leaves `NotBuiltHere` meaning exactly one thing: a component with
**no build command**, where nothing here was ever going to measure
anything and no button would change it. That is the whole difference
between it and `NeverBuilt` -- one is actionable and flagged, the other
is quiet, and they were one word covering both.
`nothing_built` takes the record map rather than reading it: a `lock()`
temporary inside a call's argument list stays held for the whole call,
and a test helper doing exactly that deadlocked the moment anything
under it wanted the same lock.
- **Freshness says *which* answer it is, and only three of them read as - **Freshness says *which* answer it is, and only three of them read as
out of date.** `Freshness` was `current | behind | unknown`, and the out of date.** `Freshness` was `current | behind | unknown`, and the
card drew unknown as nothing at all -- so a component this server had card drew unknown as nothing at all -- so a component this server had
+109 -26
View File
@@ -67,9 +67,14 @@ pub enum Freshness {
/// the comparison unreliable. Somebody is editing there and building /// the comparison unreliable. Somebody is editing there and building
/// it themselves. /// it themselves.
Uncommitted, Uncommitted,
/// Cannot tell: this server has never built it, so there is no commit /// Cannot tell, and nothing here can change that: this component has
/// to compare the build on disk against. An ordinary state for a /// no build command, so nothing on this machine was ever going to
/// project somebody builds by hand. /// produce a commit to compare against. The ordinary state for a
/// project somebody builds by hand, and deliberately *not* actionable
/// -- there is no button on the row that would clear it, which is the
/// whole difference between it and `NeverBuilt`. A component that
/// does have a command and no record is `NeverBuilt` instead, because
/// building it is exactly what fixes it.
NotBuiltHere, NotBuiltHere,
/// Cannot tell: no checkout under this project, or git would not say. /// Cannot tell: no checkout under this project, or git would not say.
NoCheckout, NoCheckout,
@@ -627,17 +632,20 @@ impl BuildState {
// one function, because the two answering it differently is what // one function, because the two answering it differently is what
// produced that: the staleness rules knew there was no build and // produced that: the staleness rules knew there was no build and
// the card did not. // the card did not.
if self.nothing_built(component) { // One read of the record, handed to both questions below. Taken
// as a whole statement rather than inline, because a temporary
// guard in a larger expression stays locked for that expression
// -- which is how a helper that passed `inner.lock()...clone()`
// straight into `component_is_stale` deadlocked the moment
// anything under it wanted the same lock.
let built_from = self.inner.lock().unwrap().built_from.clone();
if self.nothing_built(component, &built_from) {
return Freshness::NeverBuilt; return Freshness::NeverBuilt;
} }
let built = self // Only reachable for a component with no build command:
.inner // `nothing_built` above has already answered for every component
.lock() // that has one, and a missing record is `NeverBuilt` there.
.unwrap() let Some(built) = built_from.get(component.name()) else {
.built_from
.get(component.name())
.cloned();
let Some(built) = built else {
return Freshness::NotBuiltHere; return Freshness::NotBuiltHere;
}; };
match crate::git::subtree_dirty(&self.project_path, &component.watched_paths()) { match crate::git::subtree_dirty(&self.project_path, &component.watched_paths()) {
@@ -646,7 +654,7 @@ impl BuildState {
None => return Freshness::NoCheckout, None => return Freshness::NoCheckout,
} }
match crate::git::subtree_head(&self.project_path, &component.watched_paths()) { match crate::git::subtree_head(&self.project_path, &component.watched_paths()) {
Some(current) if current == built => Freshness::Current, Some(current) if &current == built => Freshness::Current,
Some(_) if parked => Freshness::Parked, Some(_) if parked => Freshness::Parked,
Some(_) => Freshness::Behind, Some(_) => Freshness::Behind,
None => Freshness::NoCheckout, None => Freshness::NoCheckout,
@@ -679,9 +687,32 @@ impl BuildState {
/// this server builds: with no command there is nothing to press, so /// this server builds: with no command there is nothing to press, so
/// saying it out loud would be a nag about a project somebody builds /// saying it out loud would be a nag about a project somebody builds
/// by hand. /// by hand.
fn nothing_built(&self, component: &Component) -> bool { fn nothing_built(&self, component: &Component, built_from: &HashMap<String, String>) -> bool {
!component.build().is_empty() if component.build().is_empty() {
&& matches!(component, Component::Apk { .. }) return false;
}
// No commit recorded means no build of this component has ever
// finished here, whatever is or is not sitting on disk. It is the
// only way to ask a `Server`, which has no output to look for --
// and it used to be asked of nothing at all, which made it the
// one state Update could not clear: with no record the component
// was not stale, so no build ran, so no record was written. The
// card said "not built here" and every button that should have
// fixed it did nothing, for ever. Measured on a two-component
// fixture: `/prepare` answered with an empty component list and
// the freshness was unchanged afterwards.
//
// Self-clearing, which is what makes it safe to act on: the first
// build writes the record and the question is never asked again.
// The cost is one rebuild per component on a machine whose
// records were lost, which is the price of every later reading
// being something this server measured rather than assumed.
if !built_from.contains_key(component.name()) {
return true;
}
// And for an APK, the output itself: a build recorded a commit and
// somebody deleted what it produced.
matches!(component, Component::Apk { .. })
&& crate::discover::find_apks(&component.dir(&self.project_path)).is_empty() && crate::discover::find_apks(&component.dir(&self.project_path)).is_empty()
} }
@@ -709,7 +740,7 @@ impl BuildState {
// never been built reports not-stale under it and would leave the // never been built reports not-stale under it and would leave the
// first build impossible to trigger -- which a project can be // first build impossible to trigger -- which a project can be
// added before having done. // added before having done.
if self.nothing_built(component) { if self.nothing_built(component, built_from) {
return true; return true;
} }
// Behind the checkout: this component was built from a commit // Behind the checkout: this component was built from a commit
@@ -1991,7 +2022,11 @@ mod tests {
.iter() .iter()
.find(|component| component.name() == name) .find(|component| component.name() == name)
.expect("component"); .expect("component");
state.component_is_stale(component, &state.inner.lock().unwrap().built_from.clone()) // Bound first: a `lock()` temporary inside the call's argument
// list stays held for the whole call, which deadlocks anything
// under it that wants the same lock.
let built_from = state.inner.lock().unwrap().built_from.clone();
state.component_is_stale(component, &built_from)
} }
/// The trap a per-component staleness check walks straight into once /// The trap a per-component staleness check walks straight into once
@@ -2129,8 +2164,10 @@ mod tests {
assert_eq!( assert_eq!(
state.freshness(app, false), state.freshness(app, false),
Freshness::NotBuiltHere, Freshness::NeverBuilt,
"no build of ours to compare, which is an ordinary state for a project built by hand", "this server has built nothing for it, which is a first build to make rather than a \
comparison to draw -- `NotBuiltHere` is now only for a component with no build \
command at all, where there is nothing to press",
); );
state state
@@ -2147,6 +2184,26 @@ mod tests {
Freshness::Uncommitted, Freshness::Uncommitted,
"somebody is editing in there, so the commit comparison says nothing", "somebody is editing in there, so the commit comparison says nothing",
); );
// The other kind of not knowing, and the one that stays quiet:
// nothing here builds this component, so there is no button that
// would ever change the answer and nothing to flag.
let by_hand = Component::Apk {
name: "by-hand".to_string(),
modes: Vec::new(),
build: crate::config::ByMode::default(),
cwd: Some(PathBuf::from("app")),
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
mode: None,
package: None,
built_from: None,
built_mode: None,
};
assert_eq!(state.freshness(&by_hand, false), Freshness::NotBuiltHere);
} }
/// Moving the checkout runs git and nothing else. /// Moving the checkout runs git and nothing else.
@@ -2427,11 +2484,25 @@ mod tests {
); );
} }
/// Nothing recorded is "we have never built this", not "this is out of /// Nothing recorded means this server has never built the component,
/// date" -- otherwise every project that existed before this feature /// and that is something to build rather than something to sit on.
/// would demand a rebuild the moment it arrived. ///
/// It was the other way round, on the grounds that every project
/// predating the record would demand one rebuild when the feature
/// arrived. The cost of that reading only showed up once freshness
/// started saying which kind of unknown it was: with no record a
/// component is not stale, so Update builds nothing, so no record is
/// ever written -- a card reading "not built here" with every button
/// that should fix it doing nothing, for ever. A `Server` cannot even
/// be rescued by the output check beside this one, having no output
/// to look for.
///
/// So the one-time rebuild is the price, and it buys the thing that
/// makes every later reading worth anything: a commit this server
/// measured rather than assumed. It is self-clearing -- the first
/// build writes the record and the question is never asked again.
#[test] #[test]
fn a_component_this_server_has_never_built_is_not_called_stale() { fn a_component_this_server_has_never_built_is_built_once() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path(); let root = dir.path();
let components = two_component_checkout(root); let components = two_component_checkout(root);
@@ -2441,8 +2512,20 @@ mod tests {
state.inner.lock().unwrap().built_from.is_empty(), state.inner.lock().unwrap().built_from.is_empty(),
"nothing recorded" "nothing recorded"
); );
assert!(!stale(&state, "backend")); assert!(stale(&state, "backend"), "a server has no output to check");
assert!(!stale(&state, "app")); assert!(stale(&state, "app"));
let head = crate::git::subtree_head(root, &[PathBuf::from("server")]).expect("a commit");
state
.inner
.lock()
.unwrap()
.built_from
.insert("backend".to_string(), head);
assert!(
!stale(&state, "backend"),
"and one build settles it, or the rebuild would repeat for ever",
);
} }
/// The same must hold where there is no checkout to compare against: /// The same must hold where there is no checkout to compare against: