Give each component build modes and a settings sheet

A component now declares its ways of being built in one list -- `modes:
["release", "debug"]`, the first the default -- and every command it runs
is handed the mode as its last argument, so a project whose script takes
`release` or `debug` names that script once. A field may instead be
written per mode, which is the escape hatch for the commands that cannot
take the word: cargo takes `--release` or nothing, and its profile for
the unoptimised build is called `dev` while the directory it writes is
called `debug`, so no single word serves as both the flag and the path.
A command written per mode is not handed the word as well; it already is
the answer, and a stray argument to a service binary is a process that
will not start.

One list rather than gathering names from whichever fields happened to
mention them is what makes a mode missing from one part unsayable: every
per-mode map is checked against it, so a gap is named rather than
resolved to some other mode's command.

Which mode to build in and whether to strip are the build machine's --
there is one checkout and one set of outputs, so a per-device mode would
have two phones rebuilding over each other silently. Which of the
finished builds a phone installs stays that phone's. Neither choice is
part of the acceptance gate, so both have to be carried across the
components list being rewritten, by acceptance and by the self entry's
startup reconciliation alike; without the second a mode chosen for this
server's own component would not survive the restart that applies it.

A mode switch moves no commit, so `builtMode` is recorded beside
`builtFrom`. Without it a component built in debug and switched to
release reads as current and serves the debug build for ever -- and for
an APK nothing else notices, because the "never built at all" check finds
any variant under the component's directory.

Each component card gains a settings sheet behind a gear at the row's
right-hand end, holding the mode, which build to install, strip, and an
Enrol button. The variant picker moved into it: on the card the two read
as one choice, both saying debug and release, and they are not. The row's
text is now bounded and truncates, so a long status can no longer push
the log and settings buttons off the edge.

`enroll:` is a declared command whose one line of stdout is a URL for the
phone to open after installing -- generic on purpose, run per press since
such a link is one-shot and carries a credential, and in the acceptance
gate because it runs on the build machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-02 06:22:05 -04:00
1 parent 0b7164bb30
commit 17d873ab7c
16 files changed
+2225 -220

No files matched your search

+166 -15
View File
@@ -416,8 +416,18 @@ impl BuildState {
self.git_pull == git_pull
&& self.git_ipv4 == git_ipv4
&& self.components.len() == components.len()
&& std::iter::zip(&self.components, components)
.all(|(mine, theirs)| mine.same_declaration(theirs))
&& std::iter::zip(&self.components, components).all(|(mine, theirs)| {
// The declaration, plus the one choice that changes what
// this state would actually run. `same_declaration`
// deliberately ignores the chosen mode -- picking one is
// not the project asking for something new -- but this
// state holds a *snapshot* of the components it builds
// from, so a mode changed underneath it would go on
// running the old mode's command from a card reporting
// the new one. Strip is not here: it is decided at
// download, and nothing this state does depends on it.
mine.same_declaration(theirs) && mine.effective_mode() == theirs.effective_mode()
})
}
/// Idempotent: kicks off the build in the background if this app is
@@ -546,6 +556,15 @@ impl BuildState {
/// uncommitted work and sending it to a phone would also be a
/// surprising thing to do with work its author has not committed.
pub fn freshness(&self, component: &Component) -> Freshness {
// Behind for a reason no commit can express: what is on disk was
// built some other way than this component is set to build now.
// Reported before the checkout is consulted at all, because it is
// a fact rather than a comparison -- a clean tree at the very
// commit the debug build was made from still does not make that
// build a release one.
if component.built_in_another_mode() {
return Freshness::Behind;
}
let built = self
.inner
.lock()
@@ -575,6 +594,16 @@ impl BuildState {
if component.build().is_empty() {
return false;
}
// The same fact `freshness` reports as Behind, and it has to be
// asked here too: for an `Apk`, the "never built at all" check
// below finds *any* variant under the component's directory, so a
// debug build sitting there is enough to make a component
// switched to release look built. Nothing else would notice --
// the commit has not moved -- so Update would do nothing and the
// phone would install the debug APK from a card saying release.
if component.built_in_another_mode() {
return true;
}
// Nothing built at all is as far behind as an output gets, and it
// is checked before any rule rather than after: a `staleWhen`
// compares two files *inside* a build, so a component that has
@@ -960,11 +989,18 @@ impl BuildState {
// wants to read afterwards.
let log = crate::logs::open_build_log(&self.key, component.name())
.map(|file| Arc::new(Mutex::new(file)));
// The mode, for a build command written once -- which is how a
// project with a script that takes `release` or `debug` says it,
// and why nothing has to be written twice in that case. Nothing
// for a command written per mode: that command already *is* the
// answer, and handing the word over as well would pass a stray
// argument to a build that never asked for one.
let mode = component.build_mode_argument();
self.run_streaming_command(
component.name(),
component.build(),
component.cwd(),
&[],
mode.as_slice(),
log,
)
}
@@ -1323,22 +1359,30 @@ mod tests {
vec![
Component::Server {
name: "backend".to_string(),
build: crate::config::Command::from_line("true"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
cwd: Some(PathBuf::from("server")),
stale_when: None,
also_watch: Vec::new(),
service: None,
mode: None,
built_from: None,
built_mode: None,
},
Component::Apk {
name: "app".to_string(),
build: crate::config::Command::from_line("true"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
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,
},
]
}
@@ -1389,13 +1433,20 @@ mod tests {
// pressed, and only the pull closes it.
let accepted = vec![Component::Apk {
name: "app".to_string(),
build: crate::config::Command::from_line("touch built-marker"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch built-marker",
)),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
package: None,
mode: None,
built_from: None,
built_mode: None,
}];
let state = state_for(&clone, accepted.clone());
@@ -1447,22 +1498,34 @@ mod tests {
let components = vec![
Component::Server {
name: "backend".to_string(),
build: crate::config::Command::from_line("touch backend-built"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch backend-built",
)),
cwd: Some(PathBuf::from("server")),
stale_when: None,
also_watch: Vec::new(),
service: None,
mode: None,
built_from: None,
built_mode: None,
},
Component::Apk {
name: "app".to_string(),
build: crate::config::Command::from_line("touch app-built"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch app-built",
)),
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,
},
];
let state = state_for(root, components);
@@ -1527,16 +1590,21 @@ mod tests {
let components = ["slow", "quick"]
.map(|name| Component::Apk {
name: name.to_string(),
build: crate::config::Command::from_line(match name {
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(match name {
"slow" => "./slow.sh",
_ => "touch quick-built",
}),
})),
cwd: Some(PathBuf::from(name)),
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,
})
.to_vec();
let state = state_for(root, components);
@@ -1598,16 +1666,21 @@ mod tests {
let components = ["broken", "fine"]
.map(|name| Component::Apk {
name: name.to_string(),
build: crate::config::Command::from_line(match name {
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(match name {
"broken" => "false",
_ => "true",
}),
})),
cwd: Some(PathBuf::from(name)),
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,
})
.to_vec();
let state = state_for(root, components);
@@ -1671,23 +1744,37 @@ mod tests {
let components = vec![
Component::Apk {
name: "a".to_string(),
build: crate::config::Command::from_line("touch a-built"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch a-built",
)),
cwd: Some(PathBuf::from("a")),
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,
},
Component::Apk {
name: "b".to_string(),
build: crate::config::Command::from_line("touch b-built"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line(
"touch b-built",
)),
cwd: Some(PathBuf::from("b")),
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,
},
];
let state = state_for(root, components);
@@ -1742,6 +1829,65 @@ mod tests {
state.component_is_stale(component, &state.inner.lock().unwrap().built_from.clone())
}
/// The trap a per-component staleness check walks straight into once
/// modes exist. An APK's "never built at all" test finds *any* build
/// under the component's directory, so a debug APK sitting there is
/// enough to make a component switched to release look built -- and
/// switching modes moves no commit, so nothing else has an opinion.
/// Update would then do nothing and the phone would install the debug
/// build from a card saying release.
///
/// Asserted through `component_is_stale` rather than through
/// `built_in_another_mode` alone, because the rule being right is not
/// the same as the check that decides whether to build asking it.
#[test]
fn a_component_switched_to_another_mode_is_stale_even_with_a_build_on_disk() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
// A build output where an APK actually lands, so the "nothing
// built here" branch is satisfied and cannot be what answers.
let built = root.join("build/outputs/apk/debug");
std::fs::create_dir_all(&built).expect("mkdir");
std::fs::write(built.join("app-debug.apk"), b"not really an apk").expect("write");
let moded = |mode: Option<&str>| {
vec![Component::Apk {
name: "app".to_string(),
modes: vec!["release".to_string(), "debug".to_string()],
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
package: None,
mode: mode.map(str::to_string),
// Built here once, in debug -- without which there is no
// build of ours for the comparison to be about.
built_from: Some("abc123".to_string()),
built_mode: Some("debug".to_string()),
}]
};
let matching = state_for(root, moded(Some("debug")));
assert!(
!stale(&matching, "app"),
"the build on disk is this mode's, so there is nothing to do",
);
let switched = state_for(root, moded(Some("release")));
assert!(
stale(&switched, "app"),
"a debug build must not satisfy a component set to build release",
);
assert_eq!(
switched.freshness(&switched.components[0]),
Freshness::Behind,
"and the card has to say so rather than reading as current",
);
}
/// The shape of bug a unit test that only calls `component_is_stale`
/// cannot see: `status()` and `trigger_if_needed` hold the lock that
/// the staleness check also wants. Taking it twice on one thread is a
@@ -1893,13 +2039,18 @@ mod tests {
root,
vec![Component::Apk {
name: "app".to_string(),
build: crate::config::Command::from_line("true"),
modes: Vec::new(),
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
cwd: None,
stale_when: None,
also_watch: Vec::new(),
strip: false,
enroll: crate::config::Command::default(),
strip_here: None,
package: None,
mode: None,
built_from: Some("0000000000000000000000000000000000000000".to_string()),
built_mode: None,
}],
);
assert!(!stale(&state, "app"), "no checkout means no opinion");