Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,738 linesCodeBlameRaw
1//! What the runner is doing, and when it is not running or is failing, why
2//! and how to fix it: the one catalogue of those words. The tray shows a
3//! diagnosis's title, the settings window all of it with its buttons, and
4//! the site shows it as the app sends it, word for word, on its Runners
5//! page (and keeps it, so a person's other computers are listed with
6//! theirs). The site adds nothing of its own: what it says of a computer
7//! whose g1t is closed is `closed` here, sent with every report.
8//!
9//! Each action is an id the settings window and the site turn into a
10//! button; bridge.rs and settings.js do what it says.
11//!
12//! The sandbox (launcher.rs) says why something is wrong in its own
13//! sentences. Those this catalogue knows (WSL not installed, virtualization
14//! off, Windows Sandbox off, a release without the sandbox's images, …)
15//! are said in its words, with a fix and its buttons; any other is shown as
16//! the launcher wrote it. With the sandbox, each kind of job it runs (Linux
17//! jobs, Windows jobs) is a card of its own (`capabilities`), with its own
18//! state, cause, fix and Start or Stop, since one can run while the other
19//! cannot, or is stopped. The whole runner's diagnosis then only says what
20//! the cards don't (`in_capabilities` is false): signed out, no workspace,
21//! not allowed, no launcher, the whole sandbox down.
22
23use serde::Serialize;
24
25use super::launcher::{self, State, Status, Why};
26
27/// Every state the runner can be in.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Code {
31 /// Running jobs is turned off in settings.
32 #[default]
33 Off,
34 /// The person pressed Stop.
35 Stopped,
36 /// Sandboxed, the person stopped every kind of job there is here.
37 KindsStopped,
38 Starting,
39 /// Waiting for the site to make a registration token.
40 Registering,
41 /// Nobody is signed in to g1t in the app.
42 SignedOut,
43 /// Signed in, owning no workspace: nowhere to register.
44 NoWorkspace,
45 /// The person may not add runners to the workspace.
46 NotAllowed,
47 RegisterFailed,
48 Idle,
49 Busy,
50 NoDocker,
51 /// The `g1t-runner` here is from before its sandbox launcher.
52 NoLauncher,
53 /// Windows, without WSL, which holds the sandbox's Linux VM.
54 NoWsl,
55 /// WSL set to mirrored networking, which reaches this computer's localhost.
56 WslMirrored,
57 /// The computer's virtualization is turned off.
58 NoVirtualization,
59 /// macOS, without vfkit, which runs the sandbox's VM.
60 NoVfkit,
61 /// Linux, with neither Sysbox in Docker nor KVM.
62 NoSysbox,
63 /// This computer lacks something the sandbox needs, by name (the
64 /// launcher's `needs`): each kind of job's card says what, and Set up
65 /// does what Windows can do itself.
66 NeedsSetup,
67 /// The newest release is published without the sandbox's images yet.
68 WaitingForImage,
69 /// The sandbox's image couldn't be downloaded, or isn't the release's.
70 ImageFailed,
71 /// The sandbox can't be made here, for a reason in the launcher's words.
72 SandboxUnavailable,
73 /// The sandbox failed, in the launcher's words.
74 SandboxFailed,
75 /// The computer has a sandbox the app didn't make, and has room for one.
76 SandboxTaken,
77 Updating,
78 UpdateFailed,
79 /// The newest release's manifest isn't signed with g1t's release key.
80 UpdateRefused,
81 Offline,
82 Crashed,
83}
84
85/// How a state should look: a dot's colour.
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
87#[serde(rename_all = "snake_case")]
88pub enum Tone {
89 Ok,
90 Working,
91 Off,
92 Warning,
93 Problem,
94}
95
96/// A one-click fix.
97#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
98pub struct Action {
99 pub id: &'static str,
100 pub label: &'static str,
101 /// Its button stays busy, after a click, until the runner's status
102 /// that follows changes (or an error, or a while with no change): the
103 /// runner starting, stopping, retrying or looking for updates. One
104 /// that opens a page, a window or copies is done once the app has it.
105 pub waits: bool,
106 /// What the person is asked before it goes ahead, for a fix that can't be undone.
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub confirm: Option<&'static str>,
109}
110
111impl Action {
112 /// A fix that is done the moment the app has it.
113 const fn now(id: &'static str, label: &'static str) -> Self {
114 Self { id, label, waits: false, confirm: None }
115 }
116
117 /// A fix whose effect the runner's status shows when it comes.
118 const fn waits(id: &'static str, label: &'static str) -> Self {
119 Self { id, label, waits: true, confirm: None }
120 }
121
122 const fn confirming(self, question: &'static str) -> Self {
123 Self { confirm: Some(question), ..self }
124 }
125}
126
127#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
128pub struct Diagnosis {
129 pub code: Code,
130 pub tone: Tone,
131 /// One line: the tray's, and the heading everywhere else.
132 pub title: String,
133 /// Why, in a sentence.
134 pub cause: Option<String>,
135 /// What to do about it.
136 pub fix: Option<String>,
137 pub actions: Vec<Action>,
138 /// With the sandbox: each kind of job it runs, as a card of its own.
139 #[serde(skip_serializing_if = "Vec::is_empty")]
140 pub capabilities: Vec<CapabilityLine>,
141 /// The cards say all of this, each for its kind of job: a page that
142 /// shows them shows no line of the whole runner's as well.
143 #[serde(skip_serializing_if = "is_false")]
144 pub in_capabilities: bool,
145 /// How the runner's updates stand: when g1t last looked, and a newer
146 /// one waiting. None for what is said once g1t is closed.
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub update: Option<UpdateLine>,
149}
150
151fn is_false(value: &bool) -> bool {
152 !*value
153}
154
155/// The runner's updates, as settings and the site's Desktops show them.
156#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
157pub struct UpdateLine {
158 /// When g1t last heard from the runner's releases, RFC 3339: the site,
159 /// which shows a report for minutes, says how long ago itself.
160 pub checked_at: Option<String>,
161 /// `Checked 12 min ago`, `Checking for updates…`.
162 pub checked: String,
163 pub checking: bool,
164 /// A newer version, downloaded and checked, waiting to be installed.
165 pub ready: Option<String>,
166 /// What becomes of it, for a runner with no kinds of job of its own
167 /// (Docker, or on this computer): `0.3.2 ready, installs after this
168 /// job`. A sandbox's cards each say it for their own kind.
169 pub note: Option<String>,
170 /// Looks now: "Check for runner updates".
171 pub check: Action,
172}
173
174/// How one kind of job is, as its card's label says it.
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
176#[serde(rename_all = "snake_case")]
177pub enum KindState {
178 /// Running a job.
179 Busy,
180 /// Waiting for jobs.
181 Idle,
182 Starting,
183 /// Downloading the sandbox's image, which `progress` says how far.
184 Downloading,
185 Updating,
186 /// Waiting for something g1t sees to by itself, such as the release's images.
187 Waiting,
188 /// Stopped: by the person (`enabled` is false), or with the whole runner.
189 Stopped,
190 /// This computer can't run it, and `cause` says why.
191 Unavailable,
192 /// It should run and doesn't: `cause` and `fix`.
193 Problem,
194}
195
196/// One kind of job the sandbox runs, `linux` or `windows` (or `macos`),
197/// and how it is: Linux jobs can run while Windows jobs cannot.
198#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
199pub struct CapabilityLine {
200 pub id: String,
201 /// `Linux jobs`.
202 pub label: String,
203 pub tone: Tone,
204 pub state: KindState,
205 /// Its state in a few words, for its label: `Waiting for jobs`, `Not available`.
206 pub title: String,
207 /// What it is, in a line: `A Linux VM of its own, each job in a fresh container`.
208 pub about: String,
209 /// What is wrong, in a few words, over its cause: `WSL isn't installed`.
210 pub headline: Option<String>,
211 pub cause: Option<String>,
212 pub fix: Option<String>,
213 pub actions: Vec<Action>,
214 /// On in settings: the person hasn't stopped it.
215 pub enabled: bool,
216 /// This computer can run it.
217 pub available: bool,
218 /// It can be started and stopped on its own here (the launcher can).
219 pub can_toggle: bool,
220 /// What it is running now.
221 pub job: Option<JobLine>,
222 /// How far its image has downloaded, in percent.
223 pub progress: Option<u8>,
224 /// How it keeps jobs from the computer: `nested-container`, `vm`.
225 pub isolation: Option<String>,
226 /// Its runner's name on g1t: `build-box-linux`.
227 pub runner: Option<String>,
228 /// Its runner's version.
229 pub version: Option<String>,
230 /// A newer version waiting for it: `0.3.2 ready, installs after this job`.
231 pub update: Option<String>,
232}
233
234/// The job a kind of job is running, with where to see it on g1t.
235#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
236pub struct JobLine {
237 /// `workflow` or `agent`.
238 pub kind: String,
239 pub name: String,
240 pub repo: String,
241 /// The repository's Actions page, where its run is.
242 pub link: Option<String>,
243}
244
245/// What the words are filled in with.
246#[derive(Clone, Debug, Default)]
247pub struct Facts {
248 pub workspace: Option<String>,
249 /// How jobs run, as `sandboxed`, `in Docker`, `on this computer`.
250 pub backend: &'static str,
251 pub machine: String,
252 pub job: Option<String>,
253 pub detail: Option<String>,
254 pub exit_code: Option<i32>,
255 pub wait_seconds: Option<u64>,
256 /// The server's host: `g1t.sh`.
257 pub server: String,
258 /// The server's address, for links: `https://g1t.sh`.
259 pub site: String,
260 pub platform: String,
261 /// What the sandbox says to do, for a cause in its own words.
262 pub fix: Option<String>,
263 /// How the sandbox is, when jobs run sandboxed.
264 pub sandbox: Option<Status>,
265 /// The kinds of job the person stopped in settings.
266 pub off: Vec<String>,
267 /// The sandbox is up, or on its way: a kind of job that should run
268 /// and doesn't is failing, rather than stopped with everything else.
269 pub live: bool,
270 /// The kinds of job a launcher command is starting now.
271 pub starting: Vec<String>,
272 /// How far the sandbox's image has downloaded, while it does.
273 pub download: Option<launcher::Download>,
274 /// The person stopped the whole runner, or turned it off.
275 pub held: bool,
276 pub update: Update,
277 /// How Set up stands (setup.rs).
278 pub setup: SetUp,
279 /// What installs QEMU here: `sudo apt install qemu-system-x86`.
280 pub qemu_install: Option<String>,
281}
282
283/// How Set up stands: what Windows can do itself for the sandbox, done
284/// behind one administrator prompt (setup.rs).
285#[derive(Clone, Debug, Default, PartialEq, Eq)]
286pub struct SetUp {
287 /// Under way, and what it does: `turns on Virtual Machine Platform and installs WSL`.
288 pub running: Option<String>,
289 /// Why the last one didn't finish, until it is tried again.
290 pub failed: Option<String>,
291 /// The person chose Later over restarting now.
292 pub later: bool,
293 /// Every kind of job's needs, of those that are on: one press of Set
294 /// up meets them all, so each card says all it does.
295 pub needs: Vec<String>,
296}
297
298/// How the runner's updates stand.
299#[derive(Clone, Debug, Default, PartialEq, Eq)]
300pub struct Update {
301 /// The g1t-runner here.
302 pub installed: Option<String>,
303 /// When g1t last heard from the releases, RFC 3339, and how long ago.
304 pub checked_at: Option<String>,
305 pub checked_ago: Option<u64>,
306 pub checking: bool,
307 /// A newer release, downloaded and checked, waiting to be put in place.
308 pub ready: Option<String>,
309 /// What is still to take it: `linux` (the sandbox's image), `windows`
310 /// (the program its runner is), or `runner` for a runner with no kinds
311 /// of job of its own.
312 pub waiting: Vec<String>,
313 /// Those taking it now.
314 pub installing: Vec<String>,
315}
316
317/// When g1t last looked for a newer runner: `Checked 12 min ago`.
318pub fn checked(update: &Update) -> String {
319 if update.checking {
320 return "Checking for updates…".into();
321 }
322 let Some(ago) = update.checked_ago else { return "Not checked yet".into() };
323 let plural = |n: u64, one: &str| if n == 1 { format!("Checked 1 {one} ago") } else { format!("Checked {n} {one}s ago") };
324 match ago {
325 0..=59 => "Checked just now".into(),
326 60..=3599 => format!("Checked {} min ago", ago / 60),
327 3600..=86_399 => plural(ago / 3600, "hour"),
328 _ => plural(ago / 86_400, "day"),
329 }
330}
331
332/// What becomes of a newer version waiting for `what` (a kind of job, or
333/// `runner`), whose job is running when `busy`. None when nothing waits.
334fn waiting(update: &Update, what: &str, busy: bool) -> Option<String> {
335 let version = update.ready.as_deref()?;
336 if update.installing.iter().any(|kind| kind == what) {
337 return some(format!("Installing {version}"));
338 }
339 if !update.waiting.iter().any(|kind| kind == what) {
340 return None;
341 }
342 some(if busy { format!("{version} ready, installs after this job") } else { format!("{version} ready, installs in a moment") })
343}
344
345fn update_line(facts: &Facts, code: Code) -> UpdateLine {
346 let update = &facts.update;
347 UpdateLine {
348 checked_at: update.checked_at.clone(),
349 checked: checked(update),
350 checking: update.checking,
351 ready: update.ready.clone(),
352 note: waiting(update, "runner", code == Code::Busy),
353 check: CHECK_UPDATES,
354 }
355}
356
357const START: Action = Action::waits("start", "Start");
358const TURN_ON: Action = Action::waits("start", "Turn on");
359const RETRY: Action = Action::waits("retry", "Retry");
360const CHECK_NOW: Action = Action::waits("retry", "Check now");
361/// Looks for a newer runner now, and installs it as soon as its kinds of job are idle.
362const CHECK_UPDATES: Action = Action::waits("check_updates", "Check for runner updates");
363const CHECK_AGAIN: Action = Action::waits("check_updates", "Check again");
364/// Removes the sandbox this computer has (`sandbox down --delete`, in the
365/// app's own folder for it) and starts the app's own: what the command a
366/// person would type does, without needing to know where that folder is.
367const REMOVE_SANDBOX: Action = Action::waits("remove_sandbox", "Remove it and start again").confirming(
368 "Remove this computer's sandbox? Its VM and registration go, and g1t makes its own in their place, registered with your workspace. Its old runners stay listed on g1t until you remove them there.",
369);
370const RESTART: Action = Action::waits("restart", "Restart");
371const SIGN_IN: Action = Action::now("sign_in", "Sign in");
372const CHOOSE_WORKSPACE: Action = Action::now("choose_workspace", "Choose workspace");
373const NEW_WORKSPACE: Action = Action::now("new_workspace", "Create a workspace");
374const CHOOSE_BACKEND: Action = Action::now("choose_backend", "Choose how jobs run");
375/// Docker takes a while to start; the runner's status says when it has.
376const OPEN_DOCKER: Action = Action::waits("open_docker", "Open Docker");
377const GET_DOCKER: Action = Action::now("get_docker", "Get Docker");
378const INSTALL_SYSBOX: Action = Action::now("install_sysbox", "Install Sysbox");
379const COPY_WSL_INSTALL: Action = Action::now("copy_wsl_install", "Copy command");
380const COPY_VFKIT_INSTALL: Action = Action::now("copy_vfkit_install", "Copy command");
381const WINDOWS_FEATURES: Action = Action::now("windows_features", "Open Windows features");
382const LEARN_MORE: Action = Action::now("learn_more", "Learn more");
383const VIEW_LOG: Action = Action::now("view_log", "View log");
384const REPORT: Action = Action::now("report", "Report");
385const VIEW_RUNNERS: Action = Action::now("view_runners", "View on g1t");
386/// What Windows can do itself for the sandbox, at once, behind its own
387/// administrator prompt (setup.rs): busy until the card says it is setting up.
388const SET_UP: Action = Action::waits("set_up", "Set up");
389const RESTART_WINDOWS: Action = Action::waits("restart_windows", "Restart now")
390 .confirming("Restart Windows now? Save your work first. g1t opens again after the restart and finishes setting up the runner.");
391const RESTART_LATER: Action = Action::waits("restart_later", "Later");
392const WINDOWS_UPDATE: Action = Action::now("windows_update", "Open Windows Update");
393const WINDOWS_ACTIVATION: Action = Action::now("windows_activation", "Open Activation");
394const FIRMWARE_HELP: Action = Action::now("firmware_help", "How to turn it on");
395const NESTED_HELP: Action = Action::now("nested_help", "How to turn it on");
396const SETUP_HELP: Action = Action::now("setup_help", "Learn more");
397const COPY_KVM_LOAD: Action = Action::now("copy_kvm_load", "Copy command");
398const COPY_KVM_GROUP: Action = Action::now("copy_kvm_group", "Copy command");
399const COPY_QEMU_INSTALL: Action = Action::now("copy_qemu_install", "Copy command");
400const GET_HOMEBREW: Action = Action::now("get_homebrew", "Get Homebrew");
401const SOFTWARE_UPDATE: Action = Action::now("software_update", "Open Software Update");
402const OPEN_RUNNER: Action = Action::now("open_runner", "See what's missing");
403
404fn some(text: impl Into<String>) -> Option<String> {
405 Some(text.into())
406}
407
408/// A wait, said: `30 seconds`, `5 minutes`.
409fn said_wait(seconds: u64) -> String {
410 match seconds {
411 0..=1 => "a moment".into(),
412 2..=89 => format!("{seconds} seconds"),
413 _ => format!("{} minutes", seconds.div_ceil(60)),
414 }
415}
416
417/// The catalogue.
418pub fn diagnose(code: Code, facts: &Facts) -> Diagnosis {
419 let workspace = facts.workspace.as_deref().unwrap_or("your workspace");
420 let detail = facts.detail.clone();
421 let capabilities = facts.sandbox.as_ref().map(|status| capabilities(status, facts)).unwrap_or_default();
422 let (tone, title, cause, fix, actions): (Tone, String, Option<String>, Option<String>, Vec<Action>) = match code {
423 Code::Off => (
424 Tone::Off,
425 "Not running: turned off".into(),
426 some("Running jobs on this computer is turned off in g1t's settings."),
427 some("Turn it on to run your workspace's self-hosted jobs here while g1t is open."),
428 vec![TURN_ON],
429 ),
430 Code::Stopped => (
431 Tone::Off,
432 "Not running: stopped".into(),
433 some("You stopped the runner. It starts again when you press Start, or when g1t next opens."),
434 None,
435 vec![START],
436 ),
437 Code::KindsStopped => (
438 Tone::Off,
439 "Not running: every kind of job is stopped".into(),
440 some("You stopped each kind of job this computer can run."),
441 some("Start one to run jobs here again."),
442 vec![],
443 ),
444 Code::Starting => (Tone::Working, "Starting".into(), detail, None, vec![]),
445 Code::Registering => (
446 Tone::Working,
447 format!("Registering with {workspace}"),
448 some(format!("g1t is registering {} as a runner for {workspace}.", facts.machine)),
449 some("Keep g1t open and signed in; it takes a few seconds."),
450 vec![],
451 ),
452 Code::SignedOut => (
453 Tone::Warning,
454 "Not running: signed out".into(),
455 some("This computer isn't registered yet, and registering it needs you signed in to g1t."),
456 some("Sign in, as an owner of the workspace the runner is for."),
457 vec![SIGN_IN],
458 ),
459 Code::NoWorkspace => (
460 Tone::Warning,
461 "Not running: no workspace to run jobs for".into(),
462 some("Only a workspace's owners add runners to it, and you don't own one."),
463 some("Ask an owner of your workspace to run it, or create a workspace of your own."),
464 vec![NEW_WORKSPACE],
465 ),
466 Code::NotAllowed => (
467 Tone::Problem,
468 format!("Couldn't register with {workspace}"),
469 some(format!("You can't add runners to {workspace}: only its owners can.")),
470 some(format!("Ask an owner of {workspace} to make you one, or choose a workspace you own.")),
471 vec![CHOOSE_WORKSPACE],
472 ),
473 Code::RegisterFailed => (
474 Tone::Problem,
475 format!("Couldn't register with {workspace}"),
476 detail.or_else(|| some("g1t didn't make a registration for this computer.")),
477 some("g1t asks again by itself in a few minutes; Retry asks now. Its log says more."),
478 vec![RETRY, VIEW_LOG],
479 ),
480 Code::Idle => (
481 Tone::Ok,
482 "Waiting for jobs".into(),
483 some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)),
484 None,
485 vec![VIEW_RUNNERS],
486 ),
487 Code::Busy => (
488 Tone::Working,
489 format!("Running {}", facts.job.as_deref().unwrap_or("a job")),
490 some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)),
491 None,
492 vec![VIEW_RUNNERS],
493 ),
494 Code::NoDocker => {
495 let app = match facts.platform.as_str() {
496 "windows" | "macos" => "Docker Desktop",
497 _ => "Docker",
498 };
499 (
500 Tone::Problem,
501 "Not running: Docker isn't running".into(),
502 some(format!("Running jobs {} needs Docker, and Docker isn't running on this computer, or isn't installed.", facts.backend)),
503 some(format!("Start {app}, or install it. The runner starts by itself once Docker answers.")),
504 if app == "Docker Desktop" { vec![OPEN_DOCKER, GET_DOCKER] } else { vec![GET_DOCKER] },
505 )
506 }
507 Code::NoLauncher => (
508 Tone::Warning,
509 "Not running: this g1t-runner has no sandbox".into(),
510 some("The g1t-runner g1t downloaded is older than its sandbox launcher, so it can't make a sandbox."),
511 some("g1t looks for a newer one by itself every few minutes; Retry looks now. Or choose to run jobs in Docker or directly on this computer: neither is a sandbox, and settings say what each can reach."),
512 vec![RETRY, CHOOSE_BACKEND],
513 ),
514 Code::NoWsl => (
515 Tone::Problem,
516 "Not running: WSL isn't installed".into(),
517 some("Linux jobs run in a WSL distribution of the runner's own, and WSL isn't installed on this computer."),
518 some(format!("Run `{}` in an admin terminal, then restart Windows. It works on every edition of Windows 11, Home included.", launcher::WSL_INSTALL)),
519 vec![COPY_WSL_INSTALL, LEARN_MORE],
520 ),
521 Code::WslMirrored => (
522 Tone::Problem,
523 "Not running: WSL uses mirrored networking".into(),
524 some("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost, so the sandbox won't start."),
525 some("Remove networkingMode=mirrored from %USERPROFILE%\\.wslconfig, run `wsl --shutdown`, then Retry."),
526 vec![RETRY, LEARN_MORE],
527 ),
528 Code::NoVirtualization => (
529 Tone::Problem,
530 "Not running: virtualization is off".into(),
531 some("The sandbox is a small virtual machine, and this computer has virtualization turned off."),
532 some(if facts.platform == "windows" {
533 "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, and Virtual Machine Platform in Windows features; then restart."
534 } else {
535 "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, then restart."
536 }),
537 if facts.platform == "windows" { vec![WINDOWS_FEATURES, LEARN_MORE] } else { vec![RETRY, LEARN_MORE] },
538 ),
539 Code::NoVfkit => (
540 Tone::Problem,
541 "Not running: vfkit isn't installed".into(),
542 some("Linux jobs run in a small VM that vfkit runs with Apple's Virtualization framework, and vfkit isn't installed."),
543 some(format!("Run `{}` in Terminal (macOS 13 or later). The runner starts by itself once it's there.", launcher::VFKIT_INSTALL)),
544 vec![COPY_VFKIT_INSTALL, LEARN_MORE],
545 ),
546 Code::NoSysbox => (
547 Tone::Problem,
548 "Not running: no Sysbox or KVM".into(),
549 some("The sandbox on Linux is the runner's image under Sysbox, or a VM under QEMU with KVM, and this computer has neither."),
550 some("Install Sysbox 0.7 or later, or turn on virtualization and install QEMU so /dev/kvm exists. The runner starts by itself once one is there."),
551 vec![INSTALL_SYSBOX, CHOOSE_BACKEND],
552 ),
553 Code::NeedsSetup => {
554 // The first kind of job that waits on something, in its card's words.
555 let first = capabilities.iter().find(|kind| kind.enabled && kind.headline.is_some() && matches!(kind.tone, Tone::Warning | Tone::Problem | Tone::Working));
556 match first {
557 Some(kind) => (
558 if kind.state == KindState::Starting { Tone::Working } else { Tone::Warning },
559 format!("Not running: {}", lowered(kind.headline.as_deref().unwrap_or_default())),
560 kind.cause.clone(),
561 kind.fix.clone(),
562 if kind.actions.is_empty() { vec![OPEN_RUNNER] } else { kind.actions.clone() },
563 ),
564 None => (
565 Tone::Warning,
566 "Not running: this computer needs setting up".into(),
567 some("The sandbox needs something this computer doesn't have yet."),
568 some("Each kind of job's card says what, and sets up what it can."),
569 vec![OPEN_RUNNER],
570 ),
571 }
572 }
573 Code::WaitingForImage => {
574 let (release, image) = detail.as_deref().and_then(launcher::missing_images).map_or((None, None), |(release, image)| (Some(release), image));
575 let release = release.map_or_else(|| "The newest g1t-runner".to_owned(), |release| format!("g1t-runner {release}"));
576 (
577 Tone::Warning,
578 "Waiting for the sandbox's image".into(),
579 some(match image {
580 Some(image) => format!("{release} is published without the sandbox's image for this kind of computer ({image}) yet, so Linux jobs can't start."),
581 None => format!("{release} is published without the sandbox's images yet, so Linux jobs can't start."),
582 }),
583 some("g1t checks again every few minutes and starts Linux jobs once they're out. There's nothing for you to do."),
584 vec![CHECK_NOW],
585 )
586 }
587 Code::ImageFailed => {
588 let said = detail.clone().unwrap_or_default().to_ascii_lowercase();
589 if said.contains("sha-256") || said.contains("signature") {
590 (
591 Tone::Problem,
592 "Not running: the sandbox's image didn't check out".into(),
593 some("The sandbox's image that downloaded isn't the one g1t's release signed, so it wasn't used."),
594 some(format!(
595 "g1t downloads it again by itself{}. If this keeps happening, something between this computer and {} is changing downloads, such as a proxy or antivirus: report it.",
596 facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(),
597 facts.server
598 )),
599 vec![RETRY, REPORT],
600 )
601 } else {
602 (
603 Tone::Problem,
604 "Not running: the sandbox's image didn't download".into(),
605 some(match &detail {
606 Some(detail) => format!("The sandbox's image couldn't be downloaded: {detail}"),
607 None => "The sandbox's image couldn't be downloaded.".into(),
608 }),
609 some(format!(
610 "g1t tries again by itself{}. Check that this computer can reach {}; Retry tries now.",
611 facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(),
612 facts.server
613 )),
614 vec![RETRY, VIEW_LOG],
615 )
616 }
617 }
618 Code::SandboxUnavailable => (
619 Tone::Problem,
620 "Not running: the sandbox can't run here".into(),
621 detail.or_else(|| some("The sandbox can't be made on this computer.")),
622 facts.fix.clone().or_else(|| some("Choose to run jobs in Docker or directly on this computer instead: neither is a sandbox, and settings say what each can reach.")),
623 vec![RETRY, LEARN_MORE, CHOOSE_BACKEND],
624 ),
625 Code::SandboxFailed => (
626 Tone::Problem,
627 "Not running: the sandbox failed".into(),
628 detail.or_else(|| some("The sandbox stopped, and didn't say why.")),
629 some(match facts.wait_seconds {
630 Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Retry starts it now.", said_wait(wait)),
631 None => "Retry starts it again; its log says why it stopped.".into(),
632 }),
633 vec![RETRY, VIEW_LOG],
634 ),
635 Code::SandboxTaken => (
636 Tone::Warning,
637 "Not running: this computer has another sandbox".into(),
638 some("This computer already has a g1t-runner sandbox that this copy of g1t has no record of making, such as one from before g1t was installed again, or one started with `g1t-runner sandbox up`. A computer has room for one."),
639 some("Remove it and start again: g1t removes it, with the jobs' VM and its registration, and makes its own, registered with your workspace. Its old runners stay listed on g1t, offline, until you remove them there. Or keep it, and turn off running jobs in g1t."),
640 vec![REMOVE_SANDBOX, LEARN_MORE],
641 ),
642 Code::Updating => (Tone::Working, "Updating".into(), detail, some("The runner starts again as soon as it's done."), vec![]),
643 Code::UpdateFailed => (
644 Tone::Warning,
645 "Update failed".into(),
646 detail,
647 some("The runner keeps running the version it has, and g1t tries the update again within the hour. Check again tries now; if it keeps failing, report it."),
648 vec![CHECK_AGAIN, REPORT],
649 ),
650 Code::UpdateRefused => {
651 let why = detail.unwrap_or_else(|| "its signature didn't check out".into());
652 let tampered = format!(
653 "If this keeps happening, something between this computer and {} is changing downloads, such as a proxy or antivirus: report it.",
654 facts.server
655 );
656 match facts.update.installed.as_deref() {
657 Some(version) => (
658 Tone::Warning,
659 "Update refused: not signed by g1t".into(),
660 some(format!("The newest g1t-runner release from {} isn't signed with g1t's release key ({why}), so g1t didn't install it.", facts.server)),
661 some(format!("The runner keeps running g1t-runner {version}, and g1t checks again within the hour. {tampered}")),
662 vec![CHECK_AGAIN, REPORT],
663 ),
664 None => (
665 Tone::Problem,
666 "Not running: the runner's release isn't signed by g1t".into(),
667 some(format!("The g1t-runner release from {} isn't signed with g1t's release key ({why}), so g1t didn't install it, and has no runner to start.", facts.server)),
668 some(format!("g1t tries again by itself in a few minutes; Retry tries now. {tampered}")),
669 vec![RETRY, REPORT],
670 ),
671 }
672 }
673 Code::Offline => (
674 Tone::Warning,
675 format!("Offline: {} unreachable", facts.server),
676 some(format!("This computer can't reach {}, so the runner can't take jobs.", facts.server)),
677 some("Check the connection. The runner carries on by itself when it's back."),
678 vec![RETRY],
679 ),
680 Code::Crashed => (
681 Tone::Problem,
682 match facts.exit_code {
683 Some(exit) => format!("Runner stopped (exit code {exit})"),
684 None => "Runner stopped".into(),
685 },
686 detail,
687 Some(match facts.wait_seconds {
688 Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Restart starts it now.", said_wait(wait)),
689 None => "Restart starts it again; its log says why it stopped.".into(),
690 }),
691 vec![RESTART, VIEW_LOG],
692 ),
693 };
694 let in_capabilities = !capabilities.is_empty()
695 && match code {
696 Code::Idle | Code::Busy | Code::KindsStopped | Code::NeedsSetup => true,
697 Code::Starting | Code::Updating => facts.live,
698 // Said by a card, for its kind of job, word for word.
699 Code::NoWsl
700 | Code::WslMirrored
701 | Code::NoVirtualization
702 | Code::NoVfkit
703 | Code::NoSysbox
704 | Code::WaitingForImage
705 | Code::ImageFailed
706 | Code::SandboxUnavailable
707 | Code::SandboxFailed => cause.is_some() && capabilities.iter().any(|c| c.cause == cause),
708 _ => false,
709 };
710 let update = Some(update_line(facts, code));
711 Diagnosis { code, tone, title, cause, fix, actions, capabilities, in_capabilities, update }
712}
713
714/// What the app calls a sandbox's problem with Linux jobs, from the
715/// launcher's sentence about it, which goes with it as its detail.
716pub fn sandbox_code(why: Why) -> Code {
717 match why {
718 Why::NoWsl => Code::NoWsl,
719 Why::WslMirrored => Code::WslMirrored,
720 Why::NoVirtualization => Code::NoVirtualization,
721 Why::NoVfkit => Code::NoVfkit,
722 Why::NoSysboxOrKvm => Code::NoSysbox,
723 Why::NoImages => Code::WaitingForImage,
724 Why::Image => Code::ImageFailed,
725 _ => Code::SandboxFailed,
726 }
727}
728
729/// The code for what is wrong with Linux jobs, as a status says it, and
730/// the launcher's own words for it.
731pub fn sandbox_trouble(status: &Status) -> Option<(Code, String, Option<String>)> {
732 let (cause, fix) = status.trouble()?;
733 let code = match launcher::why(&cause) {
734 Why::Other if !status.linux().available => Code::SandboxUnavailable,
735 why => sandbox_code(why),
736 };
737 Some((code, cause, fix))
738}
739
740/// Each kind of job the sandbox runs, as a card of its own.
741fn capabilities(status: &Status, facts: &Facts) -> Vec<CapabilityLine> {
742 let listed = if status.capabilities.is_empty() { vec![status.linux()] } else { status.capabilities.clone() };
743 listed.iter().map(|capability| capability_line(status, capability, facts)).collect()
744}
745
746/// `Linux jobs`.
747pub fn kind_label(id: &str) -> String {
748 match id {
749 "linux" => "Linux jobs".to_owned(),
750 "windows" => "Windows jobs".to_owned(),
751 "macos" => "macOS jobs".to_owned(),
752 other => format!("{other} jobs"),
753 }
754}
755
756/// What a kind of job is, in a line, by how the sandbox makes it.
757fn about(id: &str, backend: Option<&str>) -> String {
758 match (id, backend) {
759 ("linux", Some("sysbox")) => "The runner under Sysbox, each job in a fresh container".into(),
760 ("linux", _) => "A Linux VM of its own, each job in a fresh container".into(),
761 ("windows", _) => "Each job in a Windows Sandbox, discarded after".into(),
762 ("macos", _) => "Each job in a macOS VM of its own, discarded after".into(),
763 _ => "Each job sandboxed".into(),
764 }
765}
766
767/// A headline inside a sentence: `WSL isn't installed` stays, `Virtualization is off` is `virtualization is off`.
768fn lowered(headline: &str) -> String {
769 let mut letters = headline.chars();
770 let Some(first) = letters.next() else { return String::new() };
771 // An initialism (WSL, KVM, QEMU) keeps its capitals.
772 if headline.chars().nth(1).is_some_and(char::is_uppercase) {
773 return headline.to_owned();
774 }
775 first.to_lowercase().chain(letters).collect()
776}
777
778/// What a kind of job's card says of what this computer lacks for it.
779struct NeedWords {
780 state: KindState,
781 tone: Tone,
782 title: &'static str,
783 headline: String,
784 cause: String,
785 fix: Option<String>,
786 actions: Vec<Action>,
787}
788
789/// The card's words for what this computer lacks (the launcher's `needs`,
790/// first what must come first): what Set up does when Windows can do all
791/// of it, else the first, with exact steps. None for a need this app
792/// doesn't know, from a newer launcher: its own words say it then.
793fn need_words(needs: &[String], id: &str, facts: &Facts) -> Option<NeedWords> {
794 let label = kind_label(id);
795 let first = needs.first()?.as_str();
796 let words = |headline: &str, cause: String, fix: String, actions: Vec<Action>| NeedWords {
797 state: KindState::Unavailable,
798 tone: Tone::Warning,
799 title: "Needs setting up",
800 headline: headline.into(),
801 cause,
802 fix: Some(fix),
803 actions,
804 };
805 if super::setup::fixable(needs) {
806 // What one press does: for every kind of job that is on, this one's included.
807 let all = if facts.setup.needs.is_empty() { needs } else { &facts.setup.needs[..] };
808 let plan = super::setup::plan_of(all);
809 if let Some(doing) = &facts.setup.running {
810 return Some(NeedWords {
811 state: KindState::Starting,
812 tone: Tone::Working,
813 title: "Setting up",
814 headline: "Setting up".into(),
815 cause: format!("g1t {doing}. Windows asks you to allow it first, and it takes a few minutes."),
816 fix: None,
817 actions: vec![],
818 });
819 }
820 let has = |need: &str| needs.iter().any(|n| n == need);
821 let headline = if has("wsl") {
822 "WSL isn't installed"
823 } else if has("vm_platform") {
824 "Virtual Machine Platform is off"
825 } else if has("wsl_update") {
826 "WSL needs an update"
827 } else {
828 "Windows Sandbox is off"
829 };
830 let cause = if id == "linux" {
831 let mut lacks = Vec::new();
832 if has("vm_platform") {
833 lacks.push("Virtual Machine Platform turned on");
834 }
835 if has("wsl") {
836 lacks.push("WSL installed");
837 } else if has("wsl_update") {
838 lacks.push("a newer WSL");
839 }
840 format!("Linux jobs run in a small Linux VM of their own under WSL 2, which needs {}.", lacks.join(" and "))
841 } else {
842 "Windows jobs each run in a Windows Sandbox of their own, which is turned off.".to_owned()
843 };
844 let restart = if plan.features.is_empty() { "" } else { ", and to restart after" };
845 let fix = format!("Set up {} in one go. Windows asks you to allow it{restart}.", plan.said());
846 return Some(match &facts.setup.failed {
847 Some(why) => NeedWords {
848 cause: format!("Set up didn't finish: {why}"),
849 fix: Some(format!("{fix} Its log says what happened.")),
850 actions: vec![SET_UP, VIEW_LOG],
851 ..words(headline, String::new(), String::new(), vec![])
852 },
853 None => words(headline, cause, fix, vec![SET_UP, SETUP_HELP]),
854 });
855 }
856 let linux_host = facts.platform == "linux";
857 Some(match first {
858 "restart" => NeedWords {
859 title: "Restart needed",
860 ..words(
861 "Restart to finish setting up",
862 format!("Windows has turned on what {label} need, and it takes effect once Windows restarts."),
863 if facts.setup.later {
864 "Restart when it suits you: g1t opens again after, and carries on by itself.".into()
865 } else {
866 "Restart now, or later: g1t opens again after the restart and carries on by itself.".into()
867 },
868 if facts.setup.later { vec![RESTART_WINDOWS] } else { vec![RESTART_WINDOWS, RESTART_LATER] },
869 )
870 },
871 "windows_update" if id == "windows" => words(
872 "Windows needs an update",
873 "Windows jobs use Windows Sandbox's command line, which came with Windows 11 24H2, and this Windows is older.".into(),
874 "Update Windows to 24H2 or later in Windows Update, and restart. g1t carries on after.".into(),
875 vec![WINDOWS_UPDATE, CHECK_NOW],
876 ),
877 "windows_update" => words(
878 "Windows needs an update",
879 "Linux jobs run in a small Linux VM under WSL 2, and this Windows is older than the first with it, Windows 10 version 2004.".into(),
880 "Update Windows in Windows Update, and restart. g1t sets up the rest after.".into(),
881 vec![WINDOWS_UPDATE, CHECK_NOW],
882 ),
883 "virtualization" => words(
884 "Virtualization is off",
885 if linux_host {
886 "The sandbox is a small virtual machine, and this computer's processor has virtualization (Intel VT-x or AMD-V) turned off in its firmware, or has none.".into()
887 } else {
888 "The sandbox is a small virtual machine, and this computer has virtualization (Intel VT-x or AMD-V) turned off in its firmware, where only you can turn it on.".into()
889 },
890 "Restart into the firmware settings (BIOS or UEFI: a key such as F2, F10, Del or Esc as the computer starts), turn on Intel Virtualization Technology or SVM Mode, then save and restart. g1t carries on after.".into(),
891 vec![FIRMWARE_HELP, CHECK_NOW],
892 ),
893 "nested_virtualization" => words(
894 "This VM can't run the sandbox",
895 "g1t is running in a virtual machine whose host doesn't pass virtualization through to it, and the sandbox is a virtual machine inside it.".into(),
896 "On the host, turn on nested virtualization for this VM, then start it again. On Hyper-V, with the VM off: Set-VMProcessor -VMName <name> -ExposeVirtualizationExtensions $true.".into(),
897 vec![NESTED_HELP, CHECK_NOW],
898 ),
899 "windows_edition" => NeedWords {
900 tone: Tone::Off,
901 title: "Not available",
902 ..words(
903 "Not in Windows Home",
904 "Windows jobs each run in a Windows Sandbox, and Windows Home doesn't have it.".into(),
905 "Linux jobs run here all the same. Windows jobs need Windows 11 Pro, Enterprise or Education.".into(),
906 vec![WINDOWS_ACTIVATION],
907 )
908 },
909 "kvm_module" => words(
910 "KVM isn't loaded",
911 "Linux jobs run in a small VM under QEMU with KVM, and /dev/kvm isn't there, though this processor can run virtual machines.".into(),
912 format!("Load it with `{}` (Copy command), then Check now. If it says the firmware disabled it, turn on VT-x or AMD-V there.", super::launcher::KVM_LOAD),
913 vec![COPY_KVM_LOAD, CHECK_NOW],
914 ),
915 "kvm_group" => words(
916 "No access to KVM",
917 "/dev/kvm is here, but your account isn't allowed to use it.".into(),
918 format!("Run `{}` (Copy command), then log out and back in.", super::launcher::KVM_GROUP),
919 vec![COPY_KVM_GROUP, CHECK_NOW],
920 ),
921 "qemu" => words(
922 "QEMU isn't installed",
923 "Linux jobs run in a small VM under QEMU, which isn't installed.".into(),
924 match &facts.qemu_install {
925 Some(command) => format!("Install it with `{command}` (Copy command), then Check now. Or install Sysbox 0.7 or later instead."),
926 None => "Install QEMU with your package manager, then Check now. Or install Sysbox 0.7 or later instead.".into(),
927 },
928 if facts.qemu_install.is_some() { vec![COPY_QEMU_INSTALL, CHECK_NOW] } else { vec![CHECK_NOW, INSTALL_SYSBOX] },
929 ),
930 "macos_update" => words(
931 "macOS needs an update",
932 "Linux jobs run in a small VM with Apple's Virtualization framework, through vfkit, which needs macOS 13 or later.".into(),
933 "Update macOS in Software Update, then open g1t again.".into(),
934 vec![SOFTWARE_UPDATE],
935 ),
936 "homebrew" => words(
937 "vfkit isn't installed",
938 "Linux jobs run in a small VM under vfkit, which installs with Homebrew, and neither is on this Mac.".into(),
939 format!("Install Homebrew from brew.sh (Get Homebrew), then run `{}` in Terminal (Copy command). The runner starts by itself once vfkit is there.", launcher::VFKIT_INSTALL),
940 vec![GET_HOMEBREW, COPY_VFKIT_INSTALL],
941 ),
942 "vfkit" => words(
943 "vfkit isn't installed",
944 "Linux jobs run in a small VM that vfkit runs with Apple's Virtualization framework, and vfkit isn't installed.".into(),
945 format!("Run `{}` in Terminal (Copy command). The runner starts by itself once it's there.", launcher::VFKIT_INSTALL),
946 vec![COPY_VFKIT_INSTALL, CHECK_NOW],
947 ),
948 _ => return None,
949 })
950}
951
952/// A catalogue title as a card's headline: `Not running: virtualization is off` is `Virtualization is off`.
953fn headline(title: &str) -> String {
954 let said = title.strip_prefix("Not running: ").unwrap_or(title);
955 let mut letters = said.chars();
956 letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default()
957}
958
959fn capability_line(status: &Status, capability: &launcher::Capability, facts: &Facts) -> CapabilityLine {
960 let id = capability.id.as_str();
961 let label = kind_label(id);
962 let enabled = !facts.off.iter().any(|off| off == id);
963 let per_kind = status.per_kind();
964 let mut line = CapabilityLine {
965 id: id.to_owned(),
966 label: label.clone(),
967 tone: Tone::Off,
968 state: KindState::Stopped,
969 title: "Stopped".into(),
970 about: about(id, status.backend.as_deref()),
971 headline: None,
972 cause: None,
973 fix: None,
974 actions: vec![],
975 enabled,
976 available: capability.available,
977 // Nothing to start or stop where it can't run, nor while the whole runner is
978 // stopped, whose own Start starts it (one the person stopped can be started).
979 can_toggle: per_kind && (capability.available || !enabled) && !(facts.held && enabled),
980 job: None,
981 progress: None,
982 isolation: capability.isolation.clone().filter(|_| capability.available),
983 runner: capability.available.then(|| format!("{}-{id}", facts.machine)),
984 version: capability.runner_version.clone(),
985 update: waiting(&facts.update, id, capability.job.is_some()),
986 };
987 let said = |line: &mut CapabilityLine, state: KindState, tone: Tone, cause: Option<String>, fix: Option<String>, actions: Vec<Action>| {
988 line.state = state;
989 line.tone = tone;
990 line.title = match state {
991 KindState::Busy => "Running a job",
992 KindState::Idle => "Waiting for jobs",
993 KindState::Starting => "Starting",
994 KindState::Downloading => "Downloading image",
995 KindState::Updating => "Updating",
996 KindState::Waiting => "Waiting for image",
997 KindState::Stopped => "Stopped",
998 KindState::Unavailable => "Not available",
999 KindState::Problem => "Problem",
1000 }
1001 .into();
1002 line.cause = cause;
1003 line.fix = fix;
1004 line.actions = actions;
1005 };
1006 let again = |what: &str| format!("g1t {what} again by itself in a few minutes; Retry {what} now.");
1007 let starting = facts.starting.iter().any(|kind| kind == id) || matches!(capability.state, State::Starting);
1008
1009 // Stopped by the person: nothing is wrong with it.
1010 if capability.off() || (!enabled && per_kind) {
1011 said(&mut line, KindState::Stopped, Tone::Off, some(format!("You stopped {label} on this computer. They stay stopped until you start them.")), None, vec![]);
1012 return line;
1013 }
1014 // What this computer lacks for it, by name: said plainly, with Set up
1015 // for what Windows can do itself, and exact steps for the rest.
1016 if let Some(words) = need_words(&capability.needs, id, facts) {
1017 said(&mut line, words.state, words.tone, Some(words.cause), words.fix, words.actions);
1018 line.title = words.title.into();
1019 line.headline = Some(words.headline);
1020 return line;
1021 }
1022 // This computer can't run it, and why.
1023 if !capability.available {
1024 if id == "linux"
1025 && let Some((code, cause, fix)) = sandbox_trouble(status)
1026 {
1027 let whole = diagnose(code, &Facts { detail: Some(cause), fix, sandbox: None, ..facts.clone() });
1028 said(&mut line, KindState::Unavailable, Tone::Warning, whole.cause, whole.fix, whole.actions);
1029 line.headline = Some(headline(&whole.title));
1030 return line;
1031 }
1032 let cause = capability.cause.clone();
1033 let (headline, cause, fix, actions) = match cause.as_deref().map(launcher::why) {
1034 Some(Why::WindowsSandboxOff) => (
1035 "Windows Sandbox is off",
1036 some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't."),
1037 some("On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox in Windows features, then restart."),
1038 vec![WINDOWS_FEATURES],
1039 ),
1040 Some(Why::NoWsb) => (
1041 "Windows needs an update",
1042 some("This Windows has Windows Sandbox, but not its wsb command, which came with Windows 11 24H2."),
1043 some("Update Windows to 24H2 or later, then restart g1t."),
1044 vec![],
1045 ),
1046 Some(Why::MacNotReleased) => (
1047 "Not released yet",
1048 some("Sandboxed macOS jobs aren't released yet."),
1049 some("They come with a g1t-runner update, which g1t downloads by itself. There's nothing for you to do."),
1050 vec![],
1051 ),
1052 _ => ("Can't run here", cause, capability.fix.clone(), vec![LEARN_MORE]),
1053 };
1054 said(&mut line, KindState::Unavailable, Tone::Off, cause, fix, actions);
1055 line.headline = Some(headline.into());
1056 return line;
1057 }
1058 // Linux jobs that should run and don't, in the catalogue's words.
1059 if id == "linux"
1060 && !starting
1061 && !matches!(capability.state, State::Updating)
1062 && let Some((code, cause, fix)) = sandbox_trouble(status)
1063 {
1064 let wait_seconds = if facts.live { None } else { facts.wait_seconds };
1065 let whole = diagnose(code, &Facts { detail: Some(cause), fix, wait_seconds, sandbox: None, ..facts.clone() });
1066 let state = if code == Code::WaitingForImage { KindState::Waiting } else { KindState::Problem };
1067 said(&mut line, state, whole.tone, whole.cause, whole.fix, whole.actions);
1068 line.headline = Some(headline(&whole.title));
1069 return line;
1070 }
1071 // A native kind of job whose runner didn't start, in the launcher's words.
1072 if id != "linux" && capability.state == State::Error && !starting {
1073 let cause = capability.cause.clone().or_else(|| some("Its runner didn't start, and didn't say why."));
1074 said(&mut line, KindState::Problem, Tone::Problem, cause, some(again("tries")), vec![RETRY, VIEW_LOG]);
1075 line.headline = Some("Its runner didn't start".into());
1076 return line;
1077 }
1078 let downloading = facts.download.as_ref().filter(|_| id == "linux");
1079 match capability.state {
1080 _ if starting || capability.state == State::Updating => match downloading {
1081 Some(download) => {
1082 said(&mut line, KindState::Downloading, Tone::Working, some("Downloading and checking the sandbox's image, once: it takes a few minutes."), None, vec![]);
1083 line.progress = Some(download.percent);
1084 }
1085 None if capability.state == State::Updating => said(&mut line, KindState::Updating, Tone::Working, some("Taking the newest sandbox image; it takes jobs again when it's done."), None, vec![]),
1086 None if capability.state == State::Absent => said(&mut line, KindState::Starting, Tone::Working, some(format!("Registering it with {}.", facts.workspace.as_deref().unwrap_or("your workspace"))), None, vec![]),
1087 None => said(&mut line, KindState::Starting, Tone::Working, None, None, vec![]),
1088 },
1089 State::Running => match &capability.job {
1090 Some(job) => {
1091 said(&mut line, KindState::Busy, Tone::Working, None, None, vec![]);
1092 // Every job a runner takes is on the repository's Actions page:
1093 // workflow jobs, and the queue, merge check and checks tasks
1094 // the runner's wire still calls `agent` work.
1095 let link = (!job.repo.is_empty() && !facts.site.is_empty()).then(|| format!("{}/{}/actions", facts.site.trim_end_matches('/'), job.repo));
1096 line.job = Some(JobLine { kind: job.kind.clone(), name: job.name.clone(), repo: job.repo.clone(), link });
1097 }
1098 None => said(&mut line, KindState::Idle, Tone::Ok, None, None, vec![]),
1099 },
1100 // Up while the others run, yet not running: it stopped by itself,
1101 // or is to be registered.
1102 State::Stopped | State::Absent if facts.live && status.running() => {
1103 if capability.state == State::Absent {
1104 said(&mut line, KindState::Starting, Tone::Working, some(format!("Registering it with {}.", facts.workspace.as_deref().unwrap_or("your workspace"))), None, vec![]);
1105 } else {
1106 said(&mut line, KindState::Problem, Tone::Problem, some("Its runner stopped by itself."), some(again("starts it")), vec![RETRY, VIEW_LOG]);
1107 line.headline = Some("Its runner stopped".into());
1108 }
1109 }
1110 State::Stopped | State::Absent => said(&mut line, KindState::Stopped, Tone::Off, None, None, vec![]),
1111 State::Error | State::Unknown | State::Starting | State::Updating => {
1112 said(&mut line, KindState::Problem, Tone::Problem, capability.cause.clone().or_else(|| status.error.clone()), capability.fix.clone(), vec![RETRY, VIEW_LOG]);
1113 }
1114 }
1115 // The person stopped it, and this launcher can't stop one kind alone yet.
1116 if !enabled && line.fix.is_none() {
1117 line.fix = some(format!("This g1t-runner can't stop {label} on their own; the next one can, and g1t downloads it by itself."));
1118 }
1119 line
1120}
1121
1122/// The runner in a few words, for the chip beside the title bar's settings
1123/// button, which opens the Runner window: `Runner running`, `Running 2 jobs`.
1124#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
1125pub struct Chip {
1126 pub tone: Tone,
1127 pub label: String,
1128}
1129
1130/// The chip, from the whole runner's diagnosis and its kinds of job: what
1131/// it is doing, and whether anything there wants the person.
1132pub fn chip(diagnosis: &Diagnosis) -> Chip {
1133 let jobs = diagnosis.capabilities.iter().filter(|kind| kind.job.is_some()).count().max(usize::from(diagnosis.code == Code::Busy));
1134 let wanting = diagnosis.capabilities.iter().any(|kind| kind.enabled && matches!(kind.tone, Tone::Problem | Tone::Warning));
1135 let (tone, label) = match diagnosis.code {
1136 _ if jobs > 1 => (Tone::Working, format!("Running {jobs} jobs")),
1137 _ if jobs == 1 => (Tone::Working, "Running a job".to_owned()),
1138 Code::Idle => (if wanting { Tone::Warning } else { Tone::Ok }, "Runner running".to_owned()),
1139 Code::Starting | Code::Registering => (Tone::Working, "Runner starting".to_owned()),
1140 Code::Updating => (Tone::Working, "Runner updating".to_owned()),
1141 Code::UpdateFailed | Code::UpdateRefused => (Tone::Warning, "Runner running".to_owned()),
1142 Code::Off => (Tone::Off, "Runner off".to_owned()),
1143 Code::Stopped | Code::KindsStopped => (Tone::Off, "Runner stopped".to_owned()),
1144 Code::Offline => (Tone::Warning, "Runner offline".to_owned()),
1145 Code::NeedsSetup if diagnosis.tone == Tone::Working => (Tone::Working, "Runner setting up".to_owned()),
1146 _ => (diagnosis.tone, "Runner needs you".to_owned()),
1147 };
1148 Chip { tone, label }
1149}
1150
1151/// What the site says of this computer while g1t is closed on it.
1152pub fn closed(facts: &Facts) -> Diagnosis {
1153 Diagnosis {
1154 code: Code::Off,
1155 tone: Tone::Off,
1156 title: format!("g1t is closed on {}", facts.machine),
1157 cause: some(format!("This runner runs while g1t is open on {}.", facts.machine)),
1158 fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)),
1159 actions: vec![],
1160 capabilities: vec![],
1161 in_capabilities: false,
1162 update: None,
1163 }
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168 use super::*;
1169
1170 fn facts() -> Facts {
1171 Facts {
1172 workspace: Some("acme".into()),
1173 backend: "sandboxed",
1174 machine: "build-box".into(),
1175 server: "g1t.sh".into(),
1176 site: "https://g1t.sh".into(),
1177 platform: "windows".into(),
1178 ..Facts::default()
1179 }
1180 }
1181
1182 #[test]
1183 fn a_fix_that_changes_the_runner_waits_for_its_status() {
1184 for action in [START, TURN_ON, RETRY, CHECK_NOW, CHECK_UPDATES, CHECK_AGAIN, REMOVE_SANDBOX, RESTART, OPEN_DOCKER] {
1185 assert!(action.waits, "{} waits for the status that follows", action.id);
1186 }
1187 for action in [SIGN_IN, CHOOSE_WORKSPACE, CHOOSE_BACKEND, GET_DOCKER, COPY_WSL_INSTALL, LEARN_MORE, VIEW_LOG, VIEW_RUNNERS] {
1188 assert!(!action.waits, "{} is done once the app has it", action.id);
1189 }
1190 // Only what can't be undone asks first.
1191 assert!(REMOVE_SANDBOX.confirm.is_some_and(|question| question.ends_with('.') && question.contains('?')));
1192 assert!(RETRY.confirm.is_none());
1193 let said = serde_json::to_value(RETRY).unwrap();
1194 assert_eq!(said, serde_json::json!({ "id": "retry", "label": "Retry", "waits": true }));
1195 }
1196
1197 #[test]
1198 fn every_failure_says_why_and_how_to_fix_it() {
1199 for code in [
1200 Code::SignedOut,
1201 Code::NoWorkspace,
1202 Code::NotAllowed,
1203 Code::RegisterFailed,
1204 Code::NoDocker,
1205 Code::NoLauncher,
1206 Code::NoWsl,
1207 Code::WslMirrored,
1208 Code::NoVirtualization,
1209 Code::NoVfkit,
1210 Code::NoSysbox,
1211 Code::WaitingForImage,
1212 Code::ImageFailed,
1213 Code::SandboxUnavailable,
1214 Code::SandboxFailed,
1215 Code::SandboxTaken,
1216 Code::UpdateFailed,
1217 Code::UpdateRefused,
1218 Code::Offline,
1219 Code::Crashed,
1220 ] {
1221 let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() });
1222 assert!(diagnosis.cause.is_some(), "{code:?} has no cause");
1223 assert!(diagnosis.fix.is_some(), "{code:?} has no fix");
1224 assert!(!diagnosis.actions.is_empty(), "{code:?} has no action");
1225 assert!(matches!(diagnosis.tone, Tone::Warning | Tone::Problem), "{code:?}");
1226 // No fix sends the person off to check their connection or report it
1227 // when that isn't what is wrong.
1228 let fix = diagnosis.fix.unwrap();
1229 if !matches!(code, Code::Offline | Code::ImageFailed) {
1230 assert!(!fix.contains("check the connection") && !fix.contains("Check the connection"), "{code:?}: {fix}");
1231 }
1232 }
1233 }
1234
1235 #[test]
1236 fn the_words_name_the_workspace_the_machine_and_the_job() {
1237 let not_allowed = diagnose(Code::NotAllowed, &facts());
1238 assert_eq!(not_allowed.cause.as_deref(), Some("You can't add runners to acme: only its owners can."));
1239 assert_eq!(not_allowed.actions[0].id, "choose_workspace");
1240 let busy = diagnose(Code::Busy, &Facts { job: Some("Build (acme/web)".into()), ..facts() });
1241 assert_eq!(busy.title, "Running Build (acme/web)");
1242 assert_eq!(busy.cause.as_deref(), Some("Running sandboxed for acme on build-box."));
1243 let crashed = diagnose(Code::Crashed, &Facts { exit_code: Some(101), wait_seconds: Some(30), ..facts() });
1244 assert_eq!(crashed.title, "Runner stopped (exit code 101)");
1245 assert_eq!(crashed.fix.as_deref(), Some("It starts again by itself in 30 seconds. If it keeps stopping, its log says why; Restart starts it now."));
1246 assert_eq!(crashed.actions.iter().map(|a| a.id).collect::<Vec<_>>(), ["restart", "view_log"]);
1247 assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker");
1248 assert_eq!(closed(&facts()).title, "g1t is closed on build-box");
1249 assert_eq!(said_wait(300), "5 minutes");
1250 assert_eq!(said_wait(45), "45 seconds");
1251 }
1252
1253 fn status(json: &str) -> Status {
1254 launcher::parse(json).unwrap()
1255 }
1256
1257 const NO_WSL_PRO: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
1258 "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
1259 "capabilities":[
1260 {"id":"linux","available":false,"state":"error","isolation":"nested-container",
1261 "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
1262 "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows. It works on every edition of Windows 11, Home included."},
1263 {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.2.1","job":null}]}"#;
1264
1265 #[test]
1266 fn without_wsl_the_fix_is_the_command_to_copy_and_windows_jobs_still_show() {
1267 let status = status(NO_WSL_PRO);
1268 let (code, cause, _) = sandbox_trouble(&status).unwrap();
1269 assert_eq!(code, Code::NoWsl);
1270 let diagnosis = diagnose(code, &Facts { detail: Some(cause), sandbox: Some(status), live: true, ..facts() });
1271 assert_eq!(diagnosis.title, "Not running: WSL isn't installed");
1272 assert_eq!(diagnosis.fix.as_deref(), Some("Run `wsl --install --no-distribution` in an admin terminal, then restart Windows. It works on every edition of Windows 11, Home included."));
1273 assert_eq!(diagnosis.actions[0], COPY_WSL_INSTALL);
1274 let [linux, windows] = &diagnosis.capabilities[..] else { panic!("{:?}", diagnosis.capabilities) };
1275 assert_eq!((linux.label.as_str(), linux.title.as_str(), linux.state), ("Linux jobs", "Not available", KindState::Unavailable));
1276 assert_eq!(linux.headline.as_deref(), Some("WSL isn't installed"));
1277 assert_eq!(linux.fix, diagnosis.fix);
1278 assert_eq!(linux.actions[0].id, "copy_wsl_install");
1279 assert_eq!((windows.label.as_str(), windows.title.as_str(), windows.tone), ("Windows jobs", "Waiting for jobs", Tone::Ok));
1280 assert_eq!(windows.runner.as_deref(), Some("build-box-windows"));
1281 assert_eq!(windows.about, "Each job in a Windows Sandbox, discarded after");
1282 // The Linux card says it all: the whole runner's line isn't shown as well.
1283 assert!(diagnosis.in_capabilities);
1284 // An older launcher can't stop one kind of job alone.
1285 assert!(!windows.can_toggle);
1286 }
1287
1288 #[test]
1289 fn on_home_windows_jobs_are_unavailable_and_linux_jobs_still_run() {
1290 let status = status(
1291 r#"{"schema":1,"state":"running","backend":"wsl2","runner_version":"0.3.0","launcher_version":"0.3.0",
1292 "job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"},
1293 "capabilities":[
1294 {"id":"linux","available":true,"state":"running","runner_version":"0.3.0","isolation":"nested-container","turned_off":false,"job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"}},
1295 {"id":"windows","available":false,"state":"absent","turned_off":false,"cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"On Windows 11 Pro…"}]}"#,
1296 );
1297 assert_eq!(sandbox_trouble(&status), None);
1298 let busy = diagnose(Code::Busy, &Facts { job: status.job().map(launcher::Job::said), sandbox: Some(status), live: true, ..facts() });
1299 assert_eq!(busy.title, "Running build (acme/web)");
1300 assert!(busy.in_capabilities);
1301 let [linux, windows] = &busy.capabilities[..] else { panic!() };
1302 assert_eq!((linux.title.as_str(), linux.state, linux.tone), ("Running a job", KindState::Busy, Tone::Working));
1303 let job = linux.job.as_ref().unwrap();
1304 assert_eq!((job.name.as_str(), job.repo.as_str(), job.link.as_deref()), ("build", "acme/web", Some("https://g1t.sh/acme/web/actions")));
1305 assert!(linux.can_toggle && linux.enabled);
1306 assert_eq!((windows.title.as_str(), windows.tone, windows.state), ("Not available", Tone::Off, KindState::Unavailable));
1307 assert_eq!(windows.headline.as_deref(), Some("Windows Sandbox is off"));
1308 assert_eq!(windows.cause.as_deref(), Some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't."));
1309 assert_eq!(windows.actions[0].id, "windows_features");
1310 // Nothing to start or stop where it can't run.
1311 assert!(!windows.can_toggle);
1312 assert_eq!(windows.runner, None);
1313 }
1314
1315 /// 0.3.0 was published without the sandbox's images: Linux jobs wait for
1316 /// them, plainly, while Windows jobs run.
1317 const NO_IMAGES: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.3.0",
1318 "error":"Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images",
1319 "capabilities":[
1320 {"id":"linux","available":true,"state":"error","isolation":"nested-container","turned_off":false},
1321 {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.3.0","turned_off":false}]}"#;
1322
1323 #[test]
1324 fn a_release_without_images_is_waited_for_not_reported() {
1325 let status = status(NO_IMAGES);
1326 let (code, cause, _) = sandbox_trouble(&status).unwrap();
1327 assert_eq!(code, Code::WaitingForImage);
1328 let whole = diagnose(code, &Facts { detail: Some(cause), ..facts() });
1329 assert_eq!(whole.title, "Waiting for the sandbox's image");
1330 assert_eq!(whole.cause.as_deref(), Some("g1t-runner 0.3.0 is published without the sandbox's images yet, so Linux jobs can't start."));
1331 assert_eq!(whole.fix.as_deref(), Some("g1t checks again every few minutes and starts Linux jobs once they're out. There's nothing for you to do."));
1332 assert_eq!(whole.actions, [CHECK_NOW]);
1333 assert!(!whole.fix.unwrap().contains("report"));
1334 // Windows jobs run: the runner waits for jobs, and the Linux card says what it waits for.
1335 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status), live: true, ..facts() });
1336 assert!(idle.in_capabilities);
1337 let [linux, windows] = &idle.capabilities[..] else { panic!() };
1338 assert_eq!((linux.state, linux.title.as_str(), linux.tone), (KindState::Waiting, "Waiting for image", Tone::Warning));
1339 assert_eq!(linux.headline.as_deref(), Some("Waiting for the sandbox's image"));
1340 assert_eq!(windows.state, KindState::Idle);
1341 let image = diagnose(Code::ImageFailed, &Facts { detail: Some("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it".into()), ..facts() });
1342 assert_eq!(image.title, "Not running: the sandbox's image didn't check out");
1343 let fetch = diagnose(Code::ImageFailed, &Facts { detail: Some("could not fetch https://g1t.sh/x: dns error".into()), wait_seconds: Some(60), ..facts() });
1344 assert_eq!(fetch.cause.as_deref(), Some("The sandbox's image couldn't be downloaded: could not fetch https://g1t.sh/x: dns error"));
1345 assert_eq!(fetch.fix.as_deref(), Some("g1t tries again by itself in 60 seconds. Check that this computer can reach g1t.sh; Retry tries now."));
1346 }
1347
1348 #[test]
1349 fn a_kind_the_person_stopped_says_so_and_is_not_failing() {
1350 let status = status(
1351 r#"{"schema":1,"state":"running","backend":"wsl2","capabilities":[
1352 {"id":"linux","available":true,"state":"running","turned_off":false},
1353 {"id":"windows","available":true,"state":"stopped","turned_off":true}]}"#,
1354 );
1355 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status.clone()), live: true, off: vec!["windows".into()], ..facts() });
1356 let [linux, windows] = &idle.capabilities[..] else { panic!() };
1357 assert_eq!(linux.state, KindState::Idle);
1358 assert_eq!((windows.state, windows.tone, windows.enabled), (KindState::Stopped, Tone::Off, false));
1359 assert_eq!(windows.cause.as_deref(), Some("You stopped Windows jobs on this computer. They stay stopped until you start them."));
1360 assert!(windows.can_toggle && windows.actions.is_empty());
1361 // Stopped by itself while Linux jobs run is a problem, with Retry.
1362 let died = launcher::parse(&serde_json::to_string(&serde_json::json!({"schema":1,"state":"running","capabilities":[
1363 {"id":"linux","available":true,"state":"running","turned_off":false},
1364 {"id":"windows","available":true,"state":"stopped","turned_off":false}]})).unwrap()).unwrap();
1365 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(died.clone()), live: true, ..facts() });
1366 assert_eq!((idle.capabilities[1].state, idle.capabilities[1].actions[0].id), (KindState::Problem, "retry"));
1367 // With the whole runner stopped, it is only stopped.
1368 let stopped = diagnose(Code::Stopped, &Facts { sandbox: Some(died), live: false, held: true, ..facts() });
1369 // Its own Start or Stop waits for the runner's: the line above the cards starts it.
1370 assert!(!stopped.capabilities[0].can_toggle);
1371 assert_eq!(stopped.capabilities[1].state, KindState::Stopped);
1372 assert!(!stopped.in_capabilities);
1373 }
1374
1375 #[test]
1376 fn a_download_shows_how_far_it_is() {
1377 let status = status(r#"{"schema":1,"state":"starting","capabilities":[{"id":"linux","available":true,"state":"starting","turned_off":false},{"id":"windows","available":true,"state":"absent","turned_off":false}]}"#);
1378 let facts = Facts { sandbox: Some(status), live: true, starting: vec!["linux".into(), "windows".into()], download: Some(launcher::Download { key: "wsl-x64".into(), percent: 40 }), ..facts() };
1379 let starting = diagnose(Code::Starting, &facts);
1380 assert!(starting.in_capabilities);
1381 let [linux, windows] = &starting.capabilities[..] else { panic!() };
1382 assert_eq!((linux.state, linux.title.as_str(), linux.progress), (KindState::Downloading, "Downloading image", Some(40)));
1383 assert_eq!((windows.state, windows.progress), (KindState::Starting, None));
1384 }
1385
1386 #[test]
1387 fn each_launcher_trouble_has_its_words() {
1388 let trouble = |linux_available: bool, text: &str| {
1389 let json = serde_json::json!({
1390 "schema": 1, "state": "error", "error": text,
1391 "capabilities": [{"id": "linux", "available": linux_available, "state": "error", "cause": if linux_available { None } else { Some(text) }, "fix": "the launcher's fix"}]
1392 });
1393 sandbox_trouble(&status(&json.to_string())).unwrap()
1394 };
1395 assert_eq!(trouble(true, "WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.").0, Code::NoVirtualization);
1396 assert_eq!(trouble(false, "vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.").0, Code::NoVfkit);
1397 assert_eq!(trouble(false, "This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.").0, Code::NoSysbox);
1398 assert_eq!(trouble(false, "WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.").0, Code::WslMirrored);
1399 assert_eq!(trouble(true, "the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it").0, Code::ImageFailed);
1400 assert_eq!(trouble(true, "release 0.3.0 has no sandbox images").0, Code::WaitingForImage);
1401 // Unknown words: shown as the launcher wrote them, its fix with them.
1402 let (code, cause, fix) = trouble(false, "This user may not use /dev/kvm.");
1403 assert_eq!(code, Code::SandboxUnavailable);
1404 let unavailable = diagnose(code, &Facts { detail: Some(cause), fix, ..facts() });
1405 assert_eq!(unavailable.cause.as_deref(), Some("This user may not use /dev/kvm."));
1406 assert_eq!(unavailable.fix.as_deref(), Some("the launcher's fix"));
1407 let (code, cause, _) = trouble(true, "the sandbox did not start within 600 seconds");
1408 assert_eq!(code, Code::SandboxFailed);
1409 let failed = diagnose(code, &Facts { detail: Some(cause), wait_seconds: Some(60), ..facts() });
1410 assert_eq!(failed.cause.as_deref(), Some("the sandbox did not start within 600 seconds"));
1411 assert_eq!(failed.fix.as_deref(), Some("It starts again by itself in 60 seconds. If it keeps stopping, its log says why; Retry starts it now."));
1412 assert_eq!(diagnose(Code::NoVirtualization, &Facts { platform: "macos".into(), ..facts() }).actions[0].id, "retry");
1413 assert_eq!(diagnose(Code::NoVfkit, &facts()).actions[0].id, "copy_vfkit_install");
1414 }
1415
1416 #[test]
1417 fn when_g1t_last_looked_for_a_runner_is_said_plainly() {
1418 let ago = |seconds: u64| checked(&Update { checked_ago: Some(seconds), ..Update::default() });
1419 assert_eq!(checked(&Update::default()), "Not checked yet");
1420 assert_eq!(checked(&Update { checking: true, checked_ago: Some(30), ..Update::default() }), "Checking for updates…");
1421 assert_eq!(ago(20), "Checked just now");
1422 assert_eq!(ago(12 * 60 + 5), "Checked 12 min ago");
1423 assert_eq!(ago(3600), "Checked 1 hour ago");
1424 assert_eq!(ago(5 * 3600), "Checked 5 hours ago");
1425 assert_eq!(ago(2 * 86_400), "Checked 2 days ago");
1426 }
1427
1428 /// 0.3.2 is downloaded: Linux jobs take its image after the job they
1429 /// are running, Windows jobs, idle, take its program now.
1430 #[test]
1431 fn a_waiting_update_is_said_on_each_kind_of_jobs_card() {
1432 let status = status(
1433 r#"{"schema":1,"state":"running","backend":"wsl2","image_version":"0.3.1","capabilities":[
1434 {"id":"linux","available":true,"state":"running","runner_version":"0.3.1","turned_off":false,"job":{"kind":"workflow","id":"j","name":"build","repo":"acme/web"}},
1435 {"id":"windows","available":true,"state":"running","runner_version":"0.3.1","turned_off":false}]}"#,
1436 );
1437 let update = Update {
1438 installed: Some("0.3.1".into()),
1439 checked_at: Some("2026-10-12T12:00:00Z".into()),
1440 checked_ago: Some(12 * 60),
1441 ready: Some("0.3.2".into()),
1442 waiting: vec!["linux".into(), "windows".into()],
1443 ..Update::default()
1444 };
1445 let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status.clone()), live: true, update: update.clone(), ..facts() });
1446 let [linux, windows] = &busy.capabilities[..] else { panic!() };
1447 assert_eq!(linux.version.as_deref(), Some("0.3.1"));
1448 assert_eq!(linux.update.as_deref(), Some("0.3.2 ready, installs after this job"));
1449 assert_eq!(windows.update.as_deref(), Some("0.3.2 ready, installs in a moment"));
1450 let line = busy.update.unwrap();
1451 assert_eq!((line.checked.as_str(), line.ready.as_deref(), line.checked_at.as_deref()), ("Checked 12 min ago", Some("0.3.2"), Some("2026-10-12T12:00:00Z")));
1452 // The cards say it, each for its own kind: nothing is said for the whole runner.
1453 assert_eq!(line.note, None);
1454 assert_eq!((line.check.id, line.check.label), ("check_updates", "Check for runner updates"));
1455 // Windows jobs stop while the program is replaced: installing, then nothing waits.
1456 let installing = Update { installing: vec!["windows".into()], ..update.clone() };
1457 let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status.clone()), live: true, update: installing, ..facts() });
1458 assert_eq!(busy.capabilities[1].update.as_deref(), Some("Installing 0.3.2"));
1459 let done = Update { waiting: vec!["linux".into()], ..update.clone() };
1460 let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status), live: true, update: done, ..facts() });
1461 assert_eq!(busy.capabilities[1].update, None);
1462 // In Docker or on this computer there are no cards: the whole runner says it.
1463 let docker = Update { waiting: vec!["runner".into()], ..update };
1464 let busy = diagnose(Code::Busy, &Facts { backend: "in Docker", job: Some("build (acme/web)".into()), update: docker.clone(), ..facts() });
1465 assert_eq!(busy.update.unwrap().note.as_deref(), Some("0.3.2 ready, installs after this job"));
1466 let idle = diagnose(Code::Idle, &Facts { backend: "in Docker", update: docker, ..facts() });
1467 assert_eq!(idle.update.unwrap().note.as_deref(), Some("0.3.2 ready, installs in a moment"));
1468 // What is said once g1t is closed knows nothing of updates.
1469 assert_eq!(closed(&facts()).update, None);
1470 }
1471
1472 #[test]
1473 fn a_release_not_signed_by_g1t_is_refused_and_said_so() {
1474 let detail = Some("the release's signature isn't g1t's".to_owned());
1475 let running = diagnose(Code::UpdateRefused, &Facts { detail: detail.clone(), update: Update { installed: Some("0.3.1".into()), ..Update::default() }, ..facts() });
1476 assert_eq!(running.title, "Update refused: not signed by g1t");
1477 assert_eq!(
1478 running.cause.as_deref(),
1479 Some("The newest g1t-runner release from g1t.sh isn't signed with g1t's release key (the release's signature isn't g1t's), so g1t didn't install it.")
1480 );
1481 assert!(running.fix.as_deref().unwrap().starts_with("The runner keeps running g1t-runner 0.3.1, and g1t checks again within the hour."));
1482 assert_eq!(running.actions, [CHECK_AGAIN, REPORT]);
1483 assert_eq!(running.tone, Tone::Warning);
1484 // With no runner yet, nothing runs until a signed one downloads.
1485 let nothing = diagnose(Code::UpdateRefused, &Facts { detail, ..facts() });
1486 assert_eq!(nothing.title, "Not running: the runner's release isn't signed by g1t");
1487 assert_eq!((nothing.tone, nothing.actions[0].id), (Tone::Problem, "retry"));
1488 assert!(!nothing.in_capabilities);
1489 }
1490
1491 #[test]
1492 fn the_title_bars_chip_says_the_runner_in_a_few_words() {
1493 let said = |code: Code, facts: &Facts| {
1494 let chip = chip(&diagnose(code, facts));
1495 (chip.tone, chip.label)
1496 };
1497 assert_eq!(said(Code::Idle, &facts()), (Tone::Ok, "Runner running".into()));
1498 assert_eq!(said(Code::Busy, &Facts { job: Some("build (acme/web)".into()), ..facts() }), (Tone::Working, "Running a job".into()));
1499 assert_eq!(said(Code::Starting, &facts()), (Tone::Working, "Runner starting".into()));
1500 assert_eq!(said(Code::Stopped, &facts()), (Tone::Off, "Runner stopped".into()));
1501 assert_eq!(said(Code::Off, &facts()), (Tone::Off, "Runner off".into()));
1502 assert_eq!(said(Code::NoWsl, &facts()), (Tone::Problem, "Runner needs you".into()));
1503 assert_eq!(said(Code::UpdateFailed, &Facts { detail: Some("x".into()), ..facts() }).1, "Runner running");
1504 // Both kinds of job busy at once.
1505 let both = status(
1506 r#"{"schema":1,"state":"running","capabilities":[
1507 {"id":"linux","available":true,"state":"running","turned_off":false,"job":{"kind":"workflow","id":"a","name":"build","repo":"acme/web"}},
1508 {"id":"windows","available":true,"state":"running","turned_off":false,"job":{"kind":"workflow","id":"b","name":"installer","repo":"acme/app"}}]}"#,
1509 );
1510 assert_eq!(said(Code::Busy, &Facts { sandbox: Some(both), live: true, ..facts() }), (Tone::Working, "Running 2 jobs".into()));
1511 // Linux jobs run while Windows jobs can't start: running, and wanting a look.
1512 let one_failing = status(NO_WSL_PRO);
1513 assert_eq!(said(Code::Idle, &Facts { sandbox: Some(one_failing), live: true, ..facts() }), (Tone::Warning, "Runner running".into()));
1514 }
1515
1516 #[test]
1517 fn a_diagnosis_without_the_sandbox_lists_no_capabilities() {
1518 let json = serde_json::to_value(diagnose(Code::Idle, &Facts { backend: "in Docker", ..facts() })).unwrap();
1519 assert!(json.get("capabilities").is_none());
1520 assert!(json.get("in_capabilities").is_none());
1521 let sandboxed = serde_json::to_value(diagnose(Code::Idle, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() })).unwrap();
1522 assert_eq!(sandboxed["capabilities"][1]["id"], "windows");
1523 assert_eq!(sandboxed["capabilities"][1]["state"], "idle");
1524 assert_eq!(sandboxed["in_capabilities"], true);
1525 // What affects everything is said once, above the cards.
1526 let signed_out = diagnose(Code::SignedOut, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() });
1527 assert!(!signed_out.in_capabilities);
1528 }
1529
1530 // --- What this computer lacks, by name (the launcher's `needs`) ------------------
1531
1532 /// A launcher's status with each kind of job's needs: `None` for a kind
1533 /// that is running, waiting for jobs.
1534 fn needing(linux: Option<&[&str]>, native: Option<(&str, Option<&[&str]>)>) -> Status {
1535 let kind = |id: &str, needs: Option<&[&str]>| match needs {
1536 None => format!(r#"{{"id":"{id}","available":true,"state":"running","runner_version":"0.3.5","turned_off":false,"needs":[]}}"#),
1537 // What the launcher says of macOS jobs, which need nothing: they aren't released.
1538 Some([]) => format!(r#"{{"id":"{id}","available":false,"state":"absent","turned_off":false,"cause":"Sandboxed macOS jobs are not released yet.","fix":"Run macOS jobs on a runner registered with --no-docker for now.","needs":[]}}"#),
1539 Some(needs) => format!(
1540 r#"{{"id":"{id}","available":false,"state":"{}","turned_off":false,"cause":"In the launcher's words.","fix":"Its fix.","needs":{}}}"#,
1541 if id == "linux" { "error" } else { "absent" },
1542 serde_json::to_string(needs).unwrap()
1543 ),
1544 };
1545 let mut kinds = vec![kind("linux", linux)];
1546 if let Some((id, needs)) = native {
1547 kinds.push(kind(id, needs));
1548 }
1549 let state = if linux.is_none() { "running" } else { "error" };
1550 status(&format!(r#"{{"schema":1,"state":"{state}","backend":"wsl2","launcher_version":"0.3.5","capabilities":[{}]}}"#, kinds.join(",")))
1551 }
1552
1553 /// What the whole runner and each card say, as the app would show it: the code `shown` picks.
1554 fn shown_for(status: Status, facts: Facts) -> Diagnosis {
1555 let code = if status.running() { Code::Idle } else if status.linux_needs().is_empty() { Code::SandboxFailed } else { Code::NeedsSetup };
1556 // As the app fills it in: the needs of each kind that is on.
1557 let setup = SetUp { needs: super::super::setup::needs(&status, |kind| !facts.off.iter().any(|off| off == kind)), ..facts.setup.clone() };
1558 diagnose(code, &Facts { sandbox: Some(status), live: true, setup, ..facts })
1559 }
1560
1561 fn ids(actions: &[Action]) -> Vec<&'static str> {
1562 actions.iter().map(|a| a.id).collect()
1563 }
1564
1565 /// Writes each scenario's diagnosis to `G1T_SCREENS_OUT`, when it is set, for the screens.
1566 fn screen(name: &str, diagnosis: &Diagnosis) {
1567 if let Some(dir) = std::env::var_os("G1T_SCREENS_OUT") {
1568 let dir = std::path::PathBuf::from(dir);
1569 std::fs::create_dir_all(&dir).unwrap();
1570 let shown = serde_json::json!({ "diagnosis": diagnosis, "chip": chip(diagnosis) });
1571 std::fs::write(dir.join(format!("{name}.json")), serde_json::to_vec_pretty(&shown).unwrap()).unwrap();
1572 }
1573 }
1574
1575 #[test]
1576 fn a_clean_windows_home_is_set_up_with_one_button_and_says_windows_jobs_plainly() {
1577 let home = shown_for(needing(Some(&["vm_platform", "wsl"]), Some(("windows", Some(&["windows_edition"])))), facts());
1578 screen("windows-home-clean", &home);
1579 assert_eq!(home.code, Code::NeedsSetup);
1580 assert_eq!(home.title, "Not running: WSL isn't installed");
1581 assert!(home.in_capabilities);
1582 let [linux, windows] = &home.capabilities[..] else { panic!() };
1583 assert_eq!((linux.title.as_str(), linux.tone, linux.state), ("Needs setting up", Tone::Warning, KindState::Unavailable));
1584 assert_eq!(linux.headline.as_deref(), Some("WSL isn't installed"));
1585 assert_eq!(linux.cause.as_deref(), Some("Linux jobs run in a small Linux VM of their own under WSL 2, which needs Virtual Machine Platform turned on and WSL installed."));
1586 assert_eq!(linux.fix.as_deref(), Some("Set up turns on Virtual Machine Platform and installs WSL in one go. Windows asks you to allow it, and to restart after."));
1587 assert_eq!(ids(&linux.actions), ["set_up", "setup_help"]);
1588 assert!(SET_UP.waits && SET_UP.confirm.is_none(), "Windows' own prompt asks");
1589 // Home: no dead end, and no button that can't work.
1590 assert_eq!((windows.title.as_str(), windows.tone), ("Not available", Tone::Off));
1591 assert_eq!(windows.headline.as_deref(), Some("Not in Windows Home"));
1592 assert_eq!(windows.fix.as_deref(), Some("Linux jobs run here all the same. Windows jobs need Windows 11 Pro, Enterprise or Education."));
1593 assert_eq!(ids(&windows.actions), ["windows_activation"]);
1594 // The chip asks for the person.
1595 assert_eq!(chip(&home).label, "Runner needs you");
1596 }
1597
1598 #[test]
1599 fn pro_sets_up_both_kinds_and_each_says_its_own() {
1600 let pro = shown_for(needing(Some(&["vm_platform", "wsl"]), Some(("windows", Some(&["windows_sandbox"])))), facts());
1601 screen("windows-pro-clean", &pro);
1602 let [linux, windows] = &pro.capabilities[..] else { panic!() };
1603 assert_eq!(ids(&linux.actions), ["set_up", "setup_help"]);
1604 assert_eq!(windows.headline.as_deref(), Some("Windows Sandbox is off"));
1605 // One press sets up both kinds, and each card says all it does.
1606 let both = "Set up turns on Virtual Machine Platform and Windows Sandbox and installs WSL in one go. Windows asks you to allow it, and to restart after.";
1607 assert_eq!((linux.fix.as_deref(), windows.fix.as_deref()), (Some(both), Some(both)));
1608 // Windows jobs stopped by the person: Set up leaves their feature alone, and says so.
1609 let linux_only = shown_for(
1610 needing(Some(&["vm_platform", "wsl"]), Some(("windows", Some(&["windows_sandbox"])))),
1611 Facts { setup: SetUp { needs: vec!["vm_platform".into(), "wsl".into()], ..SetUp::default() }, off: vec!["windows".into()], ..facts() },
1612 );
1613 assert_eq!(linux_only.capabilities[0].fix.as_deref(), Some("Set up turns on Virtual Machine Platform and installs WSL in one go. Windows asks you to allow it, and to restart after."));
1614 // Linux jobs run while Windows Sandbox is off: waiting for jobs, and one card wants a look.
1615 let linux_runs = shown_for(needing(None, Some(("windows", Some(&["windows_sandbox"])))), facts());
1616 screen("windows-pro-sandbox-off", &linux_runs);
1617 assert_eq!(linux_runs.title, "Waiting for jobs");
1618 assert_eq!(linux_runs.capabilities[0].title, "Waiting for jobs");
1619 assert_eq!(ids(&linux_runs.capabilities[1].actions), ["set_up", "setup_help"]);
1620 assert_eq!(chip(&linux_runs), Chip { tone: Tone::Warning, label: "Runner running".into() });
1621 // Windows jobs run while Linux jobs need WSL: each kind on its own.
1622 let windows_runs = shown_for(needing(Some(&["wsl"]), Some(("windows", None))), facts());
1623 screen("windows-pro-wsl-missing", &windows_runs);
1624 assert_eq!(windows_runs.code, Code::Idle);
1625 assert_eq!(chip(&windows_runs), Chip { tone: Tone::Warning, label: "Runner running".into() });
1626 assert_eq!(windows_runs.capabilities[1].title, "Waiting for jobs");
1627 assert_eq!(windows_runs.capabilities[0].fix.as_deref(), Some("Set up installs WSL in one go. Windows asks you to allow it."));
1628 // An older WSL is updated, not installed again.
1629 let old = shown_for(needing(Some(&["wsl_update"]), None), facts());
1630 assert_eq!(old.capabilities[0].headline.as_deref(), Some("WSL needs an update"));
1631 assert_eq!(old.capabilities[0].fix.as_deref(), Some("Set up updates WSL in one go. Windows asks you to allow it."));
1632 }
1633
1634 #[test]
1635 fn set_up_shows_while_it_works_then_a_restart_now_or_later() {
1636 let clean = needing(Some(&["vm_platform", "wsl"]), Some(("windows", Some(&["windows_sandbox"]))));
1637 let working = SetUp { running: Some("turns on Virtual Machine Platform and Windows Sandbox and installs WSL".into()), ..SetUp::default() };
1638 let setting_up = shown_for(clean.clone(), Facts { setup: working, ..facts() });
1639 screen("windows-setting-up", &setting_up);
1640 assert_eq!(setting_up.title, "Not running: setting up");
1641 assert_eq!(setting_up.tone, Tone::Working);
1642 assert_eq!(chip(&setting_up), Chip { tone: Tone::Working, label: "Runner setting up".into() });
1643 for kind in &setting_up.capabilities {
1644 assert_eq!((kind.title.as_str(), kind.state, kind.tone), ("Setting up", KindState::Starting, Tone::Working));
1645 assert!(kind.actions.is_empty());
1646 assert_eq!(kind.cause.as_deref(), Some("g1t turns on Virtual Machine Platform and Windows Sandbox and installs WSL. Windows asks you to allow it first, and it takes a few minutes."));
1647 }
1648 let restart = shown_for(needing(Some(&["restart"]), Some(("windows", Some(&["restart"])))), facts());
1649 screen("windows-restart", &restart);
1650 assert_eq!(restart.title, "Not running: restart to finish setting up");
1651 let linux = &restart.capabilities[0];
1652 assert_eq!((linux.title.as_str(), linux.tone), ("Restart needed", Tone::Warning));
1653 assert_eq!(ids(&linux.actions), ["restart_windows", "restart_later"]);
1654 assert!(RESTART_WINDOWS.confirm.is_some_and(|q| q.starts_with("Restart Windows now?")));
1655 assert_eq!(linux.fix.as_deref(), Some("Restart now, or later: g1t opens again after the restart and carries on by itself."));
1656 let later = shown_for(needing(Some(&["restart"]), None), Facts { setup: SetUp { later: true, ..SetUp::default() }, ..facts() });
1657 screen("windows-restart-later", &later);
1658 assert_eq!(ids(&later.capabilities[0].actions), ["restart_windows"]);
1659 // Said No to Windows' prompt: nothing changed, and Set up again.
1660 let declined = SetUp { failed: Some("you said No to Windows' prompt, so nothing changed.".into()), ..SetUp::default() };
1661 let again = shown_for(clean, Facts { setup: declined, ..facts() });
1662 screen("windows-set-up-declined", &again);
1663 assert_eq!(again.capabilities[0].cause.as_deref(), Some("Set up didn't finish: you said No to Windows' prompt, so nothing changed."));
1664 assert_eq!(ids(&again.capabilities[0].actions), ["set_up", "view_log"]);
1665 }
1666
1667 #[test]
1668 fn what_only_the_person_can_do_is_said_step_by_step() {
1669 let firmware = shown_for(needing(Some(&["virtualization"]), Some(("windows", Some(&["virtualization"])))), facts());
1670 screen("windows-virtualization-off", &firmware);
1671 assert_eq!(firmware.title, "Not running: virtualization is off");
1672 for kind in &firmware.capabilities {
1673 assert_eq!(kind.headline.as_deref(), Some("Virtualization is off"));
1674 assert!(kind.fix.as_deref().is_some_and(|fix| fix.contains("BIOS or UEFI") && fix.contains("SVM Mode")));
1675 assert_eq!(ids(&kind.actions), ["firmware_help", "retry"]);
1676 }
1677 let nested = shown_for(needing(Some(&["nested_virtualization"]), Some(("windows", None))), facts());
1678 screen("windows-in-a-vm", &nested);
1679 assert_eq!(nested.capabilities[0].headline.as_deref(), Some("This VM can't run the sandbox"));
1680 assert!(nested.capabilities[0].fix.as_deref().is_some_and(|fix| fix.contains("ExposeVirtualizationExtensions")));
1681 let old = shown_for(needing(Some(&["windows_update"]), Some(("windows", Some(&["windows_update"])))), facts());
1682 screen("windows-too-old", &old);
1683 assert_eq!(ids(&old.capabilities[0].actions), ["windows_update", "retry"]);
1684 assert!(old.capabilities[0].cause.as_deref().is_some_and(|c| c.contains("version 2004")));
1685 assert!(old.capabilities[1].cause.as_deref().is_some_and(|c| c.contains("24H2")));
1686 }
1687
1688 #[test]
1689 fn linux_and_macos_say_the_command_that_fixes_it() {
1690 let linux = Facts { platform: "linux".into(), qemu_install: Some("sudo apt install qemu-system-x86".into()), ..facts() };
1691 let group = shown_for(needing(Some(&["kvm_group"]), None), linux.clone());
1692 screen("linux-kvm-group", &group);
1693 assert_eq!(group.title, "Not running: no access to KVM");
1694 assert_eq!(group.capabilities[0].fix.as_deref(), Some("Run `sudo usermod -aG kvm $USER` (Copy command), then log out and back in."));
1695 assert_eq!(ids(&group.capabilities[0].actions), ["copy_kvm_group", "retry"]);
1696 let bare = shown_for(needing(Some(&["kvm_module", "qemu"]), None), linux.clone());
1697 screen("linux-no-kvm", &bare);
1698 assert_eq!(bare.title, "Not running: KVM isn't loaded");
1699 assert_eq!(ids(&bare.capabilities[0].actions), ["copy_kvm_load", "retry"]);
1700 let qemu = shown_for(needing(Some(&["qemu"]), None), linux.clone());
1701 assert_eq!(qemu.capabilities[0].fix.as_deref(), Some("Install it with `sudo apt install qemu-system-x86` (Copy command), then Check now. Or install Sysbox 0.7 or later instead."));
1702 let unknown_distro = shown_for(needing(Some(&["qemu"]), None), Facts { qemu_install: None, ..linux.clone() });
1703 assert_eq!(ids(&unknown_distro.capabilities[0].actions), ["retry", "install_sysbox"]);
1704 let firmware = shown_for(needing(Some(&["virtualization"]), None), linux);
1705 assert!(firmware.capabilities[0].cause.as_deref().is_some_and(|c| c.contains("or has none")));
1706 let mac = Facts { platform: "macos".into(), ..facts() };
1707 let no_brew = shown_for(needing(Some(&["homebrew"]), Some(("macos", Some(&[])))), mac.clone());
1708 screen("macos-no-homebrew", &no_brew);
1709 assert_eq!(no_brew.title, "Not running: vfkit isn't installed");
1710 assert_eq!(ids(&no_brew.capabilities[0].actions), ["get_homebrew", "copy_vfkit_install"]);
1711 let old = shown_for(needing(Some(&["macos_update"]), None), mac);
1712 assert_eq!(ids(&old.capabilities[0].actions), ["software_update"]);
1713 }
1714
1715 #[test]
1716 fn every_need_has_a_way_forward() {
1717 let needs = [
1718 "restart", "windows_update", "virtualization", "nested_virtualization", "vm_platform", "wsl", "wsl_update", "windows_edition", "windows_sandbox", "kvm_module", "kvm_group", "qemu", "macos_update", "homebrew",
1719 "vfkit",
1720 ];
1721 for need in needs {
1722 for kind in ["linux", "windows"] {
1723 let words = need_words(&[need.to_owned()], kind, &facts()).unwrap_or_else(|| panic!("{need} has no words"));
1724 assert!(!words.actions.is_empty(), "{need} on {kind} is a dead end");
1725 assert!(words.fix.is_some_and(|fix| fix.ends_with('.')), "{need}");
1726 assert!(words.cause.ends_with('.'), "{need}");
1727 }
1728 }
1729 // A need from a newer launcher this app doesn't know: the launcher's own words.
1730 let newer = shown_for(needing(Some(&["something_new"]), None), facts());
1731 assert_eq!(newer.capabilities[0].cause.as_deref(), Some("In the launcher's words."));
1732 // From a launcher before needs: as before, by its words.
1733 let before = diagnose(Code::NoWsl, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() });
1734 assert_eq!(ids(&before.capabilities[0].actions), ["copy_wsl_install", "learn_more"]);
1735 assert_eq!(lowered("WSL isn't installed"), "WSL isn't installed");
1736 assert_eq!(lowered("No access to KVM"), "no access to KVM");
1737 }
1738}