Skip to content
697 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.1//! The one place the app meets `g1t-runner sandbox`, the launcher that
2//! makes a sandbox of the runner's own on this computer and runs the
3//! runner inside it (crates/runner/src/selfhosted/launcher): Linux jobs in
4//! a Linux VM (a WSL 2 distribution on Windows, vfkit on macOS, Sysbox or
5//! QEMU with KVM on Linux), each a fresh container inside it
6//! (`nested-container`), and, on Windows 11 Pro and up, Windows jobs each
7//! in a Windows Sandbox of its own (`vm`).
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.8//!
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.9//! It is the `g1t-runner` the app already keeps up to date (native.rs),
10//! with its state in the app's runner folder (`--state-dir`). The app runs
11//! four commands, each with `--json`, and reads the one object each prints
12//! last (the launcher's documented status, schema 1):
13//!
14//! - `up --url … --name … --labels desktop [--token …]`, after the window
15//! opens: makes the sandbox if there is none and starts it, waiting until
16//! the runner asks for work. The token goes only to the first `up`; the
17//! launcher keeps the registration from then on, across updates too. It
18//! adds `-linux` and `-windows` to the name, and the OS labels, itself.
19//! - `status`, every few seconds, for the tray, settings and the site.
20//! - `down` as g1t quits or the person stops it; `down --drain` when they
21//! choose another way of running jobs; `down --delete` when the
22//! registration is no longer the one wanted.
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.23//! - `up --only linux` and `down --only windows` when the person starts
24//! or stops one kind of job in settings: the other is left as it is.
25//! Every `up` names the kinds that are on (`--only`), so the launcher
26//! never starts one the person stopped. A launcher from before `--only`
27//! says no `turned_off` in its status, and is never sent `down --only`,
28//! which it would read as `down`, stopping everything.
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.29//! - `update`, while nothing runs: the newest signed image, the same
30//! registration.
31//!
32//! What a status's causes and errors mean is read here (`why`); what the
33//! app says about them is diagnosis.rs's.
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.34
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.35use std::ffi::OsString;
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.36use std::path::Path;
37use std::process::{Command, Stdio};
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.38use std::time::Duration;
39
40use serde::Deserialize;
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.41
42use super::backend::quiet;
43
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.44/// The launcher's state folder, under the app's runner folder.
45pub const FOLDER: &str = "sandbox";
46/// The status shape the app reads (`schema`).
47pub const SCHEMA: u32 = 1;
48/// How often the app asks the sandbox how it is: a call takes about a second.
49pub const STATUS_EVERY: Duration = Duration::from_secs(8);
50/// What installs WSL on Windows, for the person to run as administrator.
51pub const WSL_INSTALL: &str = "wsl --install --no-distribution";
52/// What installs vfkit on a Mac.
53pub const VFKIT_INSTALL: &str = "brew install vfkit";
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.54/// How often the app looks again for a release with the sandbox's images,
55/// while the newest is published without them.
56pub const IMAGES_AGAIN: Duration = Duration::from_secs(5 * 60);
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.57
58/// A sandbox's, or one capability's, state.
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum State {
62 /// Never made, or deleted.
63 #[default]
64 Absent,
65 Stopped,
66 Starting,
67 Running,
68 Updating,
69 Error,
70 /// A state a newer launcher has that this app does not know.
71 #[serde(other)]
72 Unknown,
73}
74
75/// What a runner is running now.
76#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
77#[serde(default)]
78pub struct Job {
79 /// `workflow` or `agent`.
80 pub kind: String,
81 pub id: String,
82 pub name: String,
83 pub repo: String,
84}
85
86impl Job {
87 /// How the tray says it: `build (acme/web)`.
88 pub fn said(&self) -> String {
89 if self.repo.is_empty() { self.name.clone() } else { format!("{} ({})", self.name, self.repo) }
90 }
91}
92
93/// One kind of job the computer can run sandboxed: `linux`, `windows` or `macos`.
94#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
95#[serde(default)]
96pub struct Capability {
97 pub id: String,
98 pub available: bool,
99 pub state: State,
100 pub isolation: Option<String>,
101 pub runner_version: Option<String>,
102 pub job: Option<Job>,
103 /// Why it is not available, in a sentence.
104 pub cause: Option<String>,
105 /// What to do about it, in a sentence.
106 pub fix: Option<String>,
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.107 /// The person stopped it (`down --only`): stopped, not failing. None
108 /// from a launcher before it could stop one kind of job on its own.
109 pub turned_off: Option<bool>,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.110}
111
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.112impl Capability {
113 pub fn off(&self) -> bool {
114 self.turned_off == Some(true)
115 }
116}
117
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.118/// `g1t-runner sandbox status --json`. The top-level fields are the Linux
119/// VM's. Fields the app does not use (`resources`, `state_dir`) are left
120/// out, and any a newer launcher adds are ignored.
121#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
122#[serde(default)]
123pub struct Status {
124 pub schema: u32,
125 pub state: State,
126 /// `wsl2`, `vfkit`, `qemu` or `sysbox`.
127 pub backend: Option<String>,
128 pub runner_version: Option<String>,
129 pub launcher_version: Option<String>,
130 pub image_version: Option<String>,
131 pub isolation: Option<String>,
132 pub job: Option<Job>,
133 /// A sentence for a person, whenever something is wrong.
134 pub error: Option<String>,
135 pub capabilities: Vec<Capability>,
136}
137
138impl Status {
139 pub fn capability(&self, id: &str) -> Option<&Capability> {
140 self.capabilities.iter().find(|c| c.id == id)
141 }
142
143 /// The Linux capability, as the top-level fields say it when a launcher lists none.
144 pub fn linux(&self) -> Capability {
145 self.capability("linux").cloned().unwrap_or_else(|| Capability {
146 id: "linux".into(),
147 available: self.state != State::Error || self.error.is_none(),
148 state: self.state,
149 runner_version: self.runner_version.clone(),
150 job: self.job.clone(),
151 ..Capability::default()
152 })
153 }
154
155 /// Whether any of its runners is running.
156 pub fn running(&self) -> bool {
157 self.state == State::Running || self.capabilities.iter().any(|c| c.state == State::Running)
158 }
159
160 /// Whether anything here can be sandboxed at all.
161 pub fn anything_available(&self) -> bool {
162 if self.capabilities.is_empty() { self.linux().available } else { self.capabilities.iter().any(|c| c.available) }
163 }
164
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.165 /// A capability that can be had here, and is on, but was never set
166 /// up: registering it needs a token.
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.167 pub fn unregistered(&self) -> bool {
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.168 let wanting = |c: &Capability| c.available && !c.off() && c.state == State::Absent;
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.169 if self.capabilities.is_empty() { wanting(&self.linux()) } else { self.capabilities.iter().any(wanting) }
170 }
171
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.172 /// Whether this launcher starts and stops each kind of job on its own
173 /// (`--only`), which it says by saying which are turned off.
174 pub fn per_kind(&self) -> bool {
175 !self.capabilities.is_empty() && self.capabilities.iter().all(|c| c.turned_off.is_some())
176 }
177
178 /// Whether everything that could run here was turned off by the person.
179 pub fn all_off(&self) -> bool {
180 let available: Vec<&Capability> = self.capabilities.iter().filter(|c| c.available || c.off()).collect();
181 !available.is_empty() && available.iter().all(|c| c.off())
182 }
183
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.184 /// What a runner is running, the Linux one's first.
185 pub fn job(&self) -> Option<&Job> {
186 self.job.as_ref().or_else(|| self.capabilities.iter().find_map(|c| c.job.as_ref()))
187 }
188
189 /// The runner's version, as the tray and the site say it.
190 pub fn version(&self) -> Option<String> {
191 self.runner_version
192 .clone()
193 .or_else(|| self.capabilities.iter().find_map(|c| c.runner_version.clone()))
194 .or_else(|| self.launcher_version.clone())
195 }
196
197 /// What is wrong with Linux jobs, in the launcher's words: its error,
198 /// else why the capability is unavailable.
199 pub fn trouble(&self) -> Option<(String, Option<String>)> {
200 let linux = self.linux();
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.201 // Stopped by the person: nothing is wrong with it.
202 if linux.off() {
203 return None;
204 }
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.205 if !linux.available {
206 let cause = linux.cause.or_else(|| self.error.clone())?;
207 return Some((cause, linux.fix));
208 }
209 if linux.state == State::Error || self.state == State::Error {
210 return Some((self.error.clone().unwrap_or_else(|| "The sandbox stopped, and didn't say why.".into()), None));
211 }
212 None
213 }
214}
215
216/// Reads what a launcher command printed: log lines, then the status as
217/// one JSON object, which starts on a line of its own. A launcher whose
218/// schema is not this app's is not read, nor is a `g1t-runner` from before
219/// the launcher, which prints no status at all.
220pub fn parse(output: &str) -> Result<Status, String> {
221 let start = output
222 .match_indices('\n')
223 .map(|(at, _)| at + 1)
224 .chain(std::iter::once(0))
225 .filter(|at| output[*at..].starts_with('{'))
226 .max()
227 .ok_or_else(|| "this g1t-runner printed no sandbox status: it is older than its sandbox launcher".to_owned())?;
228 let status: Status = serde_json::from_str(output[start..].trim()).map_err(|e| format!("the sandbox's status doesn't read: {e}"))?;
229 if status.schema != SCHEMA {
230 return Err(format!("the sandbox's status is in schema {}, and this g1t reads schema {SCHEMA}: update g1t", status.schema));
231 }
232 Ok(status)
233}
234
235/// Whether a line of a launcher's output starts its status, rather than being a log line.
236pub fn starts_status(line: &str) -> bool {
237 line.starts_with('{')
238}
239
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.240/// How far the sandbox's image has downloaded, from the launcher's log.
241#[derive(Clone, Debug, PartialEq, Eq)]
242pub struct Download {
243 /// `wsl-x64`.
244 pub key: String,
245 pub percent: u8,
246}
247
248/// Reads `Fetching the sandbox's wsl-x64 image (612 MB)`, the start of a
249/// download, and `Fetching the sandbox's wsl-x64 image: 40% of 612 MB`,
250/// each tenth of it (the launcher's image.rs), after the log's time.
251pub fn download(line: &str) -> Option<Download> {
252 let (_, rest) = line.split_once("Fetching the sandbox's ")?;
253 let (key, rest) = rest.split_once(" image")?;
254 let percent = match rest.strip_prefix(": ") {
255 Some(said) => said.split_once('%')?.0.trim().parse::<u8>().ok()?.min(100),
256 None => 0,
257 };
258 Some(Download { key: key.to_owned(), percent })
259}
260
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.261/// What `up` is told the first time, and again on every start (the
262/// launcher keeps what it was told, and `--token` only matters once).
263#[derive(Clone, PartialEq, Eq)]
264pub struct Up<'a> {
265 pub url: &'a str,
266 pub name: &'a str,
267 pub labels: &'a str,
268 /// The registration token: only for a sandbox that has no registration yet.
269 pub token: Option<&'a str>,
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.270 /// The kinds of job to start (`--only`): those the person has on.
271 pub only: Vec<&'a str>,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.272}
273
274// Never print a token, in a log or a panic.
275impl std::fmt::Debug for Up<'_> {
276 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.277 f.debug_struct("Up")
278 .field("url", &self.url)
279 .field("name", &self.name)
280 .field("labels", &self.labels)
281 .field("token", &self.token.map(|_| "…"))
282 .field("only", &self.only)
283 .finish()
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.284 }
285}
286
287fn args(action: &str, dir: &Path) -> Vec<OsString> {
288 let mut args: Vec<OsString> = vec!["sandbox".into(), action.into(), "--json".into(), "--state-dir".into()];
289 args.push(dir.as_os_str().to_owned());
290 args
291}
292
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.293/// `sandbox up`'s arguments. The token is never one of them: anyone on the
294/// computer can read a process's command line while it runs, so it goes in
295/// `up`'s environment instead ([`with_token`]).
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.296pub fn up_args(dir: &Path, up: &Up) -> Vec<OsString> {
297 let mut args = args("up", dir);
298 for (name, value) in [("--url", up.url), ("--name", up.name), ("--labels", up.labels)] {
299 args.push(name.into());
300 args.push(value.into());
301 }
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.302 if !up.only.is_empty() {
303 args.push("--only".into());
304 args.push(up.only.join(",").into());
305 }
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.306 args
307}
308
309/// Gives a launcher command the registration token, as `G1T_RUNNER_TOKEN`
310/// in its environment and nowhere else.
311pub fn with_token(command: &mut Command, token: Option<&str>) {
312 if let Some(token) = token {
313 command.env("G1T_RUNNER_TOKEN", token);
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.314 }
315}
316
317/// How `down` stops the sandbox.
318#[derive(Clone, Copy, Debug, PartialEq, Eq)]
319pub enum Down {
320 /// Now: a running job is cut short (as g1t quits, or Stop).
321 Now,
322 /// After the running job (another way of running jobs was chosen).
323 Drain,
324 /// Now, and the VM and the registration with it (another workspace or server).
325 Delete,
326}
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.327
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.328pub fn down_args(dir: &Path, how: Down) -> Vec<OsString> {
329 let mut args = args("down", dir);
330 match how {
331 Down::Now => {}
332 Down::Drain => args.push("--drain".into()),
333 Down::Delete => args.push("--delete".into()),
334 }
335 args
336}
337
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.338/// `down --only <kind>`: that kind of job stops, and stays off; the others
339/// are left as they are. Only for a launcher whose status is [`Status::per_kind`].
340pub fn down_only_args(dir: &Path, kind: &str) -> Vec<OsString> {
341 let mut args = args("down", dir);
342 args.push("--only".into());
343 args.push(kind.into());
344 args
345}
346
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.347pub fn status_args(dir: &Path) -> Vec<OsString> {
348 args("status", dir)
349}
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.350
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.351pub fn update_args(dir: &Path) -> Vec<OsString> {
352 args("update", dir)
353}
354
355/// A launcher command whose output the app reads a line at a time
356/// (`up` and `update`, which take minutes the first time).
357pub fn command(binary: &Path, args: Vec<OsString>) -> Command {
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.358 let mut command = quiet(binary);
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.359 // What the app's own environment may have set for another runner is not this one's.
360 for (name, _) in std::env::vars_os() {
361 if name.to_string_lossy().starts_with("G1T_RUNNER_") {
362 command.env_remove(&name);
363 }
364 }
365 command.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.366 command
367}
368
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.369/// Runs a short launcher command to its end and reads its status, which
370/// it prints whether it worked or not.
371fn run(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.372 run_done(binary, args).map(|(status, _)| status)
373}
374
375/// [`run`], and whether the command did what it was asked (its exit code).
376fn run_done(binary: &Path, args: Vec<OsString>) -> Result<(Status, bool), String> {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.377 let out = command(binary, args).output().map_err(|e| format!("g1t-runner could not start: {e}"))?;
378 let printed = String::from_utf8_lossy(&out.stdout);
379 match parse(&printed) {
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.380 Ok(status) => Ok((status, out.status.success())),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.381 Err(_) if !out.status.success() && !out.stderr.is_empty() => Err(String::from_utf8_lossy(&out.stderr).trim().trim_start_matches("g1t-runner: ").to_owned()),
382 Err(why) => Err(why),
383 }
384}
385
386/// How the sandbox is now.
387pub fn status(binary: &Path, dir: &Path) -> Result<Status, String> {
388 run(binary, status_args(dir))
389}
390
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.391/// Stops the sandbox, waiting for it: how it is now.
392pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<Status, String> {
393 down_with(binary, down_args(dir, how))
394}
395
396/// Stops one kind of job, and keeps it off, waiting for it.
397pub fn down_only(binary: &Path, dir: &Path, kind: &str) -> Result<Status, String> {
398 down_with(binary, down_only_args(dir, kind))
399}
400
401fn down_with(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.402 if !binary.exists() {
403 return Err("g1t-runner isn't installed".into());
404 }
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.405 match run_done(binary, args)? {
406 (status, true) => Ok(status),
407 (status, false) => Err(status.error.unwrap_or_else(|| "g1t-runner sandbox down failed.".into())),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.408 }
409}
410
411/// What a launcher's sentence is about: the words the app shows for it
412/// are diagnosis.rs's. Read from the causes and errors the launcher
413/// writes (crates/runner/src/selfhosted/launcher and native.rs).
414#[derive(Clone, Copy, Debug, PartialEq, Eq)]
415pub enum Why {
416 NoWsl,
417 WslMirrored,
418 NoVirtualization,
419 NoVfkit,
420 /// Linux, with neither Sysbox in Docker nor KVM.
421 NoSysboxOrKvm,
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.422 /// The newest release is published without the sandbox's images (for
423 /// this computer), so there is nothing to download yet.
424 NoImages,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.425 /// The sandbox's image couldn't be downloaded, or isn't the release's.
426 Image,
427 /// g1t refused the registration token.
428 TokenRefused,
429 /// g1t no longer knows the runner.
430 Forgotten,
431 /// The sandbox has no registration, and no token was given.
432 Unregistered,
433 /// Windows Sandbox is off, or this edition of Windows has none.
434 WindowsSandboxOff,
435 /// Windows Sandbox without `wsb`, from before Windows 11 24H2.
436 NoWsb,
437 /// Sandboxed macOS jobs are not released.
438 MacNotReleased,
439 /// Anything else: shown in the launcher's own words.
440 Other,
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.441}
442
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.443/// The release a "has no sandbox images" sentence names, and the image
444/// it lacks when it is only this computer's: `release 0.3.0 has no
445/// sandbox image vm-arm64-disk for this machine`.
446pub fn missing_images(text: &str) -> Option<(String, Option<String>)> {
447 let (before, after) = text.split_once(" has no sandbox image")?;
448 let version = before.rsplit("release ").next()?.trim().to_owned();
449 let image = after.strip_prefix(' ').and_then(|rest| rest.split_whitespace().next()).map(str::to_owned);
450 (!version.is_empty() && !version.contains(' ')).then_some((version, image))
451}
452
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.453pub fn why(text: &str) -> Why {
454 let lower = text.to_ascii_lowercase();
455 let has = |words: &str| lower.contains(words);
456 if has("wsl is not installed") {
457 Why::NoWsl
458 } else if has("mirrored") {
459 Why::WslMirrored
460 } else if has("virtualization is not enabled")
461 || has("enable virtualization")
462 || has("virtual machine platform")
463 || has("hcs_e_hyperv_not_installed")
464 || has("vt-x")
465 || has("hypervisor is not present")
466 {
467 Why::NoVirtualization
468 } else if has("vfkit") && has("not installed") {
469 Why::NoVfkit
470 } else if has("neither sysbox nor kvm") || has("docker has no sysbox") {
471 Why::NoSysboxOrKvm
472 } else if has("token") && (has("not valid") || has("expired") || has("invalid")) {
473 Why::TokenRefused
474 } else if has("no longer knows this runner") || has("was removed from g1t") {
475 Why::Forgotten
476 } else if has("is not registered") {
477 Why::Unregistered
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.478 } else if has("has no sandbox image") {
479 Why::NoImages
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.480 } else if has("sandbox image") || has("sandbox's images") || has("no sandbox images") || has("sha-256") || has("signature") || has("release key") || has("could not fetch") || has("broke off") {
481 Why::Image
482 } else if has("windows sandbox is not turned on") {
483 Why::WindowsSandboxOff
484 } else if has("24h2") {
485 Why::NoWsb
486 } else if has("macos jobs are not released") {
487 Why::MacNotReleased
488 } else {
489 Why::Other
490 }
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.491}
492
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.493#[cfg(test)]
494mod tests {
495 use super::*;
496 use std::path::PathBuf;
497
498 /// What `status --json` printed on a Windows 11 Pro machine with WSL
499 /// and Windows Sandbox, before `up` (g1t-runner 0.2.1).
500 const ABSENT: &str = r#"{
501 "schema": 1,
502 "state": "absent",
503 "backend": "wsl2",
504 "runner_version": null,
505 "launcher_version": "0.2.1",
506 "image_version": null,
507 "isolation": "nested-container",
508 "job": null,
509 "error": null,
510 "resources": {
511 "cpus": 0,
512 "memory_mib": 0,
513 "disk_gib": 0
514 },
515 "capabilities": [
516 {
517 "id": "linux",
518 "available": true,
519 "state": "absent",
520 "isolation": "nested-container",
521 "runner_version": null,
522 "job": null,
523 "cause": null,
524 "fix": null
525 },
526 {
527 "id": "windows",
528 "available": true,
529 "state": "absent",
530 "isolation": "vm",
531 "runner_version": null,
532 "job": null,
533 "cause": null,
534 "fix": null
535 }
536 ],
537 "state_dir": "C:\\Users\\syntaqx\\.g1t-runner\\sandbox"
538}"#;
539
540 /// The launcher's documented example: running a job, Windows Sandbox off.
541 const RUNNING: &str = r#"{
542 "schema": 1, "state": "running", "backend": "wsl2",
543 "runner_version": "0.3.0", "launcher_version": "0.3.0", "image_version": "0.3.0",
544 "isolation": "nested-container",
545 "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" },
546 "error": null,
547 "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 },
548 "capabilities": [
549 { "id": "linux", "available": true, "state": "running", "isolation": "nested-container",
550 "runner_version": "0.3.0", "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" }, "cause": null, "fix": null },
551 { "id": "windows", "available": false, "state": "absent", "isolation": "vm",
552 "runner_version": null, "job": null,
553 "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
554 "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." }
555 ],
556 "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox",
557 "something_new": { "a": 1 }
558}"#;
559
560 #[test]
561 fn a_status_is_read_from_after_the_log() {
562 let printed = format!("2026-10-12T08:00:00Z Downloading the sandbox image wsl-x64.\n2026-10-12T08:03:00Z {{not json}}\n{RUNNING}\n");
563 let status = parse(&printed).unwrap();
564 assert_eq!(status.state, State::Running);
565 assert_eq!(status.backend.as_deref(), Some("wsl2"));
566 assert_eq!(status.job().map(Job::said).as_deref(), Some("build (acme/web)"));
567 assert_eq!(status.version().as_deref(), Some("0.3.0"));
568 assert!(status.running());
569 assert!(!status.unregistered());
570 let windows = status.capability("windows").unwrap();
571 assert!(!windows.available);
572 assert_eq!(why(windows.cause.as_deref().unwrap()), Why::WindowsSandboxOff);
573 assert_eq!(status.trouble(), None);
574 }
575
576 #[test]
577 fn a_sandbox_never_made_needs_registering() {
578 let status = parse(ABSENT).unwrap();
579 assert_eq!(status.state, State::Absent);
580 assert!(status.anything_available());
581 assert!(status.unregistered());
582 assert!(!status.running());
583 assert_eq!(status.version().as_deref(), Some("0.2.1"));
584 }
585
586 #[test]
587 fn what_is_not_a_status_is_said_so() {
588 assert!(parse("g1t-runner: unknown command sandbox\n").unwrap_err().contains("older than its sandbox launcher"));
589 assert!(parse(&ABSENT.replace("\"schema\": 1", "\"schema\": 2")).unwrap_err().contains("schema 2"));
590 let unknown = parse(&ABSENT.replace("\"state\": \"absent\",\n \"backend\"", "\"state\": \"hibernating\",\n \"backend\"")).unwrap();
591 assert_eq!(unknown.state, State::Unknown);
592 assert!(starts_status("{") && !starts_status(" \"schema\": 1,"));
593 }
594
595 #[test]
596 fn without_wsl_linux_says_why_and_nothing_else_can_run_on_home() {
597 let status = parse(
598 r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
599 "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
600 "capabilities":[
601 {"id":"linux","available":false,"state":"error",
602 "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
603 "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows."},
604 {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"…"}]}"#,
605 )
606 .unwrap();
607 assert!(!status.anything_available());
608 let (cause, fix) = status.trouble().unwrap();
609 assert_eq!(why(&cause), Why::NoWsl);
610 assert!(fix.unwrap().contains(WSL_INSTALL));
611 }
612
613 #[test]
614 fn the_launchers_sentences_are_told_apart() {
615 for (text, expected) in [
616 ("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.", Why::WslMirrored),
617 ("WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.", Why::NoVirtualization),
618 ("vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.", Why::NoVfkit),
619 ("This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.", Why::NoSysboxOrKvm),
620 ("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it", Why::Image),
621 ("could not fetch https://g1t.sh/downloads/runner/0.3.0/wsl-x64.tar.gz: dns error", Why::Image),
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.622 ("release 0.3.0 has no sandbox images", Why::NoImages),
623 ("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images", Why::NoImages),
624 ("release 0.3.0 has no sandbox image vm-arm64-disk for this machine", Why::NoImages),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.625 ("The registration token is not valid, or has expired.", Why::TokenRefused),
626 ("g1t no longer knows this runner (401 unauthorized). Register it again.", Why::Forgotten),
627 ("the sandbox's runner is not registered, and no --url and --token were given", Why::Unregistered),
628 ("This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.", Why::NoWsb),
629 ("Sandboxed macOS jobs are not released yet.", Why::MacNotReleased),
630 ("This user may not use /dev/kvm.", Why::Other),
631 ] {
632 assert_eq!(why(text), expected, "{text}");
633 }
634 }
635
636 #[test]
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.637 fn a_kind_turned_off_is_stopped_by_the_person() {
638 let status = parse(
639 r#"{"schema":1,"state":"stopped","capabilities":[
640 {"id":"linux","available":true,"state":"stopped","turned_off":true},
641 {"id":"windows","available":true,"state":"absent","turned_off":false}]}"#,
642 )
643 .unwrap();
644 assert!(status.per_kind());
645 assert!(status.capability("linux").unwrap().off());
646 assert!(!status.all_off());
647 assert_eq!(status.trouble(), None);
648 // Only Windows, never set up, wants registering; Linux is off.
649 assert!(status.unregistered());
650 let all = parse(r#"{"schema":1,"state":"stopped","capabilities":[{"id":"linux","available":true,"state":"stopped","turned_off":true},{"id":"windows","available":false,"state":"absent","turned_off":false}]}"#).unwrap();
651 assert!(all.all_off());
652 assert!(!all.unregistered());
653 // A launcher from before `--only` says nothing of it.
654 assert!(!parse(RUNNING).unwrap().per_kind());
655 }
656
657 #[test]
658 fn a_download_says_how_far_it_is() {
659 assert_eq!(download("2026-10-12T08:00:00Z Fetching the sandbox's wsl-x64 image (612 MB)"), Some(Download { key: "wsl-x64".into(), percent: 0 }));
660 assert_eq!(download("2026-10-12T08:01:00Z Fetching the sandbox's wsl-x64 image: 40% of 612 MB"), Some(Download { key: "wsl-x64".into(), percent: 40 }));
661 assert_eq!(download("2026-10-12T08:01:00Z Took build (acme/web)"), None);
662 assert_eq!(missing_images("release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None)));
663 assert_eq!(missing_images("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None)));
664 assert_eq!(missing_images("release 0.4.0 has no sandbox image vm-arm64-disk for this machine"), Some(("0.4.0".into(), Some("vm-arm64-disk".into()))));
665 assert_eq!(missing_images("could not fetch"), None);
666 }
667
668 #[test]
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.669 fn the_commands_are_the_launchers() {
670 let dir = PathBuf::from("data").join("runner").join(FOLDER);
671 let text = |args: Vec<OsString>| args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.672 let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret"), only: vec!["linux", "windows"] };
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.673 let up = text(up_args(&dir, &first));
674 assert_eq!(&up[..4], ["sandbox", "up", "--json", "--state-dir"]);
675 assert_eq!(up[4], dir.to_string_lossy());
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.676 // Never the token: it goes in the environment, out of the process list.
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.677 assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--only", "linux,windows"]);
678 assert_eq!(text(down_only_args(&dir, "windows"))[5..], ["--only", "windows"]);
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.679 assert!(!up.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
680 let mut command = command(Path::new("g1t-runner"), up_args(&dir, &first));
681 with_token(&mut command, first.token);
682 assert!(command.get_envs().any(|(name, value)| name == "G1T_RUNNER_TOKEN" && value == Some(std::ffi::OsStr::new("g1trt_secret"))));
683 assert!(!command.get_args().any(|a| a.to_string_lossy().contains("g1trt_")));
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.684 // Its name and labels as the app has them: the launcher adds `-linux` and the OS labels.
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.685 assert!(!up.iter().any(|a| a.ends_with("-linux")));
686 let again = text(up_args(&dir, &Up { token: None, only: vec![], ..first.clone() }));
687 assert!(!again.iter().any(|a| a == "--only"));
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.688 assert!(!again.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
689 assert!(!format!("{first:?}").contains("g1trt_secret"));
690 assert_eq!(text(down_args(&dir, Down::Now))[..3], ["sandbox", "down", "--json"]);
691 assert_eq!(text(down_args(&dir, Down::Now)).len(), 5);
692 assert_eq!(text(down_args(&dir, Down::Drain)).last().unwrap(), "--drain");
693 assert_eq!(text(down_args(&dir, Down::Delete)).last().unwrap(), "--delete");
694 assert_eq!(text(status_args(&dir))[..3], ["sandbox", "status", "--json"]);
695 assert_eq!(text(update_args(&dir))[..3], ["sandbox", "update", "--json"]);
696 }
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.697}

This file's history is long; its oldest lines are credited to the oldest commit read.