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:
1 parent
ce1f02b98f
commit
663626345a
2 files changed
+137
-26
No files matched your search
@@ -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
@@ -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 ¤t == 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:
|
||||||
|
|||||||
Reference in new issue
Block a user