Skip to content
1,275 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 /// The newest release is published without the sandbox's images yet.
64 WaitingForImage,
65 /// The sandbox's image couldn't be downloaded, or isn't the release's.
66 ImageFailed,
67 /// The sandbox can't be made here, for a reason in the launcher's words.
68 SandboxUnavailable,
69 /// The sandbox failed, in the launcher's words.
70 SandboxFailed,
71 /// The computer has a sandbox the app didn't make, and has room for one.
72 SandboxTaken,
73 Updating,
74 UpdateFailed,
75 /// The newest release's manifest isn't signed with g1t's release key.
76 UpdateRefused,
77 Offline,
78 Crashed,
79}
80
81/// How a state should look: a dot's colour.
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum Tone {
85 Ok,
86 Working,
87 Off,
88 Warning,
89 Problem,
90}
91
92/// A one-click fix.
93#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
94pub struct Action {
95 pub id: &'static str,
96 pub label: &'static str,
97 /// Its button stays busy, after a click, until the runner's status
98 /// that follows changes (or an error, or a while with no change): the
99 /// runner starting, stopping, retrying or looking for updates. One
100 /// that opens a page, a window or copies is done once the app has it.
101 pub waits: bool,
102 /// What the person is asked before it goes ahead, for a fix that can't be undone.
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub confirm: Option<&'static str>,
105}
106
107impl Action {
108 /// A fix that is done the moment the app has it.
109 const fn now(id: &'static str, label: &'static str) -> Self {
110 Self { id, label, waits: false, confirm: None }
111 }
112
113 /// A fix whose effect the runner's status shows when it comes.
114 const fn waits(id: &'static str, label: &'static str) -> Self {
115 Self { id, label, waits: true, confirm: None }
116 }
117
118 const fn confirming(self, question: &'static str) -> Self {
119 Self { confirm: Some(question), ..self }
120 }
121}
122
123#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
124pub struct Diagnosis {
125 pub code: Code,
126 pub tone: Tone,
127 /// One line: the tray's, and the heading everywhere else.
128 pub title: String,
129 /// Why, in a sentence.
130 pub cause: Option<String>,
131 /// What to do about it.
132 pub fix: Option<String>,
133 pub actions: Vec<Action>,
134 /// With the sandbox: each kind of job it runs, as a card of its own.
135 #[serde(skip_serializing_if = "Vec::is_empty")]
136 pub capabilities: Vec<CapabilityLine>,
137 /// The cards say all of this, each for its kind of job: a page that
138 /// shows them shows no line of the whole runner's as well.
139 #[serde(skip_serializing_if = "is_false")]
140 pub in_capabilities: bool,
141 /// How the runner's updates stand: when g1t last looked, and a newer
142 /// one waiting. None for what is said once g1t is closed.
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub update: Option<UpdateLine>,
145}
146
147fn is_false(value: &bool) -> bool {
148 !*value
149}
150
151/// The runner's updates, as settings and the site's Desktops show them.
152#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
153pub struct UpdateLine {
154 /// When g1t last heard from the runner's releases, RFC 3339: the site,
155 /// which shows a report for minutes, says how long ago itself.
156 pub checked_at: Option<String>,
157 /// `Checked 12 min ago`, `Checking for updates…`.
158 pub checked: String,
159 pub checking: bool,
160 /// A newer version, downloaded and checked, waiting to be installed.
161 pub ready: Option<String>,
162 /// What becomes of it, for a runner with no kinds of job of its own
163 /// (Docker, or on this computer): `0.3.2 ready, installs after this
164 /// job`. A sandbox's cards each say it for their own kind.
165 pub note: Option<String>,
166 /// Looks now: "Check for runner updates".
167 pub check: Action,
168}
169
170/// How one kind of job is, as its card's label says it.
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
172#[serde(rename_all = "snake_case")]
173pub enum KindState {
174 /// Running a job.
175 Busy,
176 /// Waiting for jobs.
177 Idle,
178 Starting,
179 /// Downloading the sandbox's image, which `progress` says how far.
180 Downloading,
181 Updating,
182 /// Waiting for something g1t sees to by itself, such as the release's images.
183 Waiting,
184 /// Stopped: by the person (`enabled` is false), or with the whole runner.
185 Stopped,
186 /// This computer can't run it, and `cause` says why.
187 Unavailable,
188 /// It should run and doesn't: `cause` and `fix`.
189 Problem,
190}
191
192/// One kind of job the sandbox runs, `linux` or `windows` (or `macos`),
193/// and how it is: Linux jobs can run while Windows jobs cannot.
194#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
195pub struct CapabilityLine {
196 pub id: String,
197 /// `Linux jobs`.
198 pub label: String,
199 pub tone: Tone,
200 pub state: KindState,
201 /// Its state in a few words, for its label: `Waiting for jobs`, `Not available`.
202 pub title: String,
203 /// What it is, in a line: `A Linux VM of its own, each job in a fresh container`.
204 pub about: String,
205 /// What is wrong, in a few words, over its cause: `WSL isn't installed`.
206 pub headline: Option<String>,
207 pub cause: Option<String>,
208 pub fix: Option<String>,
209 pub actions: Vec<Action>,
210 /// On in settings: the person hasn't stopped it.
211 pub enabled: bool,
212 /// This computer can run it.
213 pub available: bool,
214 /// It can be started and stopped on its own here (the launcher can).
215 pub can_toggle: bool,
216 /// What it is running now.
217 pub job: Option<JobLine>,
218 /// How far its image has downloaded, in percent.
219 pub progress: Option<u8>,
220 /// How it keeps jobs from the computer: `nested-container`, `vm`.
221 pub isolation: Option<String>,
222 /// Its runner's name on g1t: `build-box-linux`.
223 pub runner: Option<String>,
224 /// Its runner's version.
225 pub version: Option<String>,
226 /// A newer version waiting for it: `0.3.2 ready, installs after this job`.
227 pub update: Option<String>,
228}
229
230/// The job a kind of job is running, with where to see it on g1t.
231#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
232pub struct JobLine {
233 /// `workflow` or `agent`.
234 pub kind: String,
235 pub name: String,
236 pub repo: String,
237 /// The repository's Actions (or Agents) page, where its run is.
238 pub link: Option<String>,
239}
240
241/// What the words are filled in with.
242#[derive(Clone, Debug, Default)]
243pub struct Facts {
244 pub workspace: Option<String>,
245 /// How jobs run, as `sandboxed`, `in Docker`, `on this computer`.
246 pub backend: &'static str,
247 pub machine: String,
248 pub job: Option<String>,
249 pub detail: Option<String>,
250 pub exit_code: Option<i32>,
251 pub wait_seconds: Option<u64>,
252 /// The server's host: `g1t.sh`.
253 pub server: String,
254 /// The server's address, for links: `https://g1t.sh`.
255 pub site: String,
256 pub platform: String,
257 /// What the sandbox says to do, for a cause in its own words.
258 pub fix: Option<String>,
259 /// How the sandbox is, when jobs run sandboxed.
260 pub sandbox: Option<Status>,
261 /// The kinds of job the person stopped in settings.
262 pub off: Vec<String>,
263 /// The sandbox is up, or on its way: a kind of job that should run
264 /// and doesn't is failing, rather than stopped with everything else.
265 pub live: bool,
266 /// The kinds of job a launcher command is starting now.
267 pub starting: Vec<String>,
268 /// How far the sandbox's image has downloaded, while it does.
269 pub download: Option<launcher::Download>,
270 /// The person stopped the whole runner, or turned it off.
271 pub held: bool,
272 pub update: Update,
273}
274
275/// How the runner's updates stand.
276#[derive(Clone, Debug, Default, PartialEq, Eq)]
277pub struct Update {
278 /// The g1t-runner here.
279 pub installed: Option<String>,
280 /// When g1t last heard from the releases, RFC 3339, and how long ago.
281 pub checked_at: Option<String>,
282 pub checked_ago: Option<u64>,
283 pub checking: bool,
284 /// A newer release, downloaded and checked, waiting to be put in place.
285 pub ready: Option<String>,
286 /// What is still to take it: `linux` (the sandbox's image), `windows`
287 /// (the program its runner is), or `runner` for a runner with no kinds
288 /// of job of its own.
289 pub waiting: Vec<String>,
290 /// Those taking it now.
291 pub installing: Vec<String>,
292}
293
294/// When g1t last looked for a newer runner: `Checked 12 min ago`.
295pub fn checked(update: &Update) -> String {
296 if update.checking {
297 return "Checking for updates…".into();
298 }
299 let Some(ago) = update.checked_ago else { return "Not checked yet".into() };
300 let plural = |n: u64, one: &str| if n == 1 { format!("Checked 1 {one} ago") } else { format!("Checked {n} {one}s ago") };
301 match ago {
302 0..=59 => "Checked just now".into(),
303 60..=3599 => format!("Checked {} min ago", ago / 60),
304 3600..=86_399 => plural(ago / 3600, "hour"),
305 _ => plural(ago / 86_400, "day"),
306 }
307}
308
309/// What becomes of a newer version waiting for `what` (a kind of job, or
310/// `runner`), whose job is running when `busy`. None when nothing waits.
311fn waiting(update: &Update, what: &str, busy: bool) -> Option<String> {
312 let version = update.ready.as_deref()?;
313 if update.installing.iter().any(|kind| kind == what) {
314 return some(format!("Installing {version}"));
315 }
316 if !update.waiting.iter().any(|kind| kind == what) {
317 return None;
318 }
319 some(if busy { format!("{version} ready, installs after this job") } else { format!("{version} ready, installs in a moment") })
320}
321
322fn update_line(facts: &Facts, code: Code) -> UpdateLine {
323 let update = &facts.update;
324 UpdateLine {
325 checked_at: update.checked_at.clone(),
326 checked: checked(update),
327 checking: update.checking,
328 ready: update.ready.clone(),
329 note: waiting(update, "runner", code == Code::Busy),
330 check: CHECK_UPDATES,
331 }
332}
333
334const START: Action = Action::waits("start", "Start");
335const TURN_ON: Action = Action::waits("start", "Turn on");
336const RETRY: Action = Action::waits("retry", "Retry");
337const CHECK_NOW: Action = Action::waits("retry", "Check now");
338/// Looks for a newer runner now, and installs it as soon as its kinds of job are idle.
339const CHECK_UPDATES: Action = Action::waits("check_updates", "Check for runner updates");
340const CHECK_AGAIN: Action = Action::waits("check_updates", "Check again");
341/// Removes the sandbox this computer has (`sandbox down --delete`, in the
342/// app's own folder for it) and starts the app's own: what the command a
343/// person would type does, without needing to know where that folder is.
344const REMOVE_SANDBOX: Action = Action::waits("remove_sandbox", "Remove it and start again").confirming(
345 "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.",
346);
347const RESTART: Action = Action::waits("restart", "Restart");
348const SIGN_IN: Action = Action::now("sign_in", "Sign in");
349const CHOOSE_WORKSPACE: Action = Action::now("choose_workspace", "Choose workspace");
350const NEW_WORKSPACE: Action = Action::now("new_workspace", "Create a workspace");
351const CHOOSE_BACKEND: Action = Action::now("choose_backend", "Choose how jobs run");
352/// Docker takes a while to start; the runner's status says when it has.
353const OPEN_DOCKER: Action = Action::waits("open_docker", "Open Docker");
354const GET_DOCKER: Action = Action::now("get_docker", "Get Docker");
355const INSTALL_SYSBOX: Action = Action::now("install_sysbox", "Install Sysbox");
356const COPY_WSL_INSTALL: Action = Action::now("copy_wsl_install", "Copy command");
357const COPY_VFKIT_INSTALL: Action = Action::now("copy_vfkit_install", "Copy command");
358const WINDOWS_FEATURES: Action = Action::now("windows_features", "Open Windows features");
359const LEARN_MORE: Action = Action::now("learn_more", "Learn more");
360const VIEW_LOG: Action = Action::now("view_log", "View log");
361const REPORT: Action = Action::now("report", "Report");
362const VIEW_RUNNERS: Action = Action::now("view_runners", "View on g1t");
363
364fn some(text: impl Into<String>) -> Option<String> {
365 Some(text.into())
366}
367
368/// A wait, said: `30 seconds`, `5 minutes`.
369fn said_wait(seconds: u64) -> String {
370 match seconds {
371 0..=1 => "a moment".into(),
372 2..=89 => format!("{seconds} seconds"),
373 _ => format!("{} minutes", seconds.div_ceil(60)),
374 }
375}
376
377/// The catalogue.
378pub fn diagnose(code: Code, facts: &Facts) -> Diagnosis {
379 let workspace = facts.workspace.as_deref().unwrap_or("your workspace");
380 let detail = facts.detail.clone();
381 let (tone, title, cause, fix, actions): (Tone, String, Option<String>, Option<String>, Vec<Action>) = match code {
382 Code::Off => (
383 Tone::Off,
384 "Not running: turned off".into(),
385 some("Running jobs on this computer is turned off in g1t's settings."),
386 some("Turn it on to run your workspace's self-hosted jobs here while g1t is open."),
387 vec![TURN_ON],
388 ),
389 Code::Stopped => (
390 Tone::Off,
391 "Not running: stopped".into(),
392 some("You stopped the runner. It starts again when you press Start, or when g1t next opens."),
393 None,
394 vec![START],
395 ),
396 Code::KindsStopped => (
397 Tone::Off,
398 "Not running: every kind of job is stopped".into(),
399 some("You stopped each kind of job this computer can run."),
400 some("Start one to run jobs here again."),
401 vec![],
402 ),
403 Code::Starting => (Tone::Working, "Starting".into(), detail, None, vec![]),
404 Code::Registering => (
405 Tone::Working,
406 format!("Registering with {workspace}"),
407 some(format!("g1t is registering {} as a runner for {workspace}.", facts.machine)),
408 some("Keep g1t open and signed in; it takes a few seconds."),
409 vec![],
410 ),
411 Code::SignedOut => (
412 Tone::Warning,
413 "Not running: signed out".into(),
414 some("This computer isn't registered yet, and registering it needs you signed in to g1t."),
415 some("Sign in, as an owner of the workspace the runner is for."),
416 vec![SIGN_IN],
417 ),
418 Code::NoWorkspace => (
419 Tone::Warning,
420 "Not running: no workspace to run jobs for".into(),
421 some("Only a workspace's owners add runners to it, and you don't own one."),
422 some("Ask an owner of your workspace to run it, or create a workspace of your own."),
423 vec![NEW_WORKSPACE],
424 ),
425 Code::NotAllowed => (
426 Tone::Problem,
427 format!("Couldn't register with {workspace}"),
428 some(format!("You can't add runners to {workspace}: only its owners can.")),
429 some(format!("Ask an owner of {workspace} to make you one, or choose a workspace you own.")),
430 vec![CHOOSE_WORKSPACE],
431 ),
432 Code::RegisterFailed => (
433 Tone::Problem,
434 format!("Couldn't register with {workspace}"),
435 detail.or_else(|| some("g1t didn't make a registration for this computer.")),
436 some("g1t asks again by itself in a few minutes; Retry asks now. Its log says more."),
437 vec![RETRY, VIEW_LOG],
438 ),
439 Code::Idle => (
440 Tone::Ok,
441 "Waiting for jobs".into(),
442 some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)),
443 None,
444 vec![VIEW_RUNNERS],
445 ),
446 Code::Busy => (
447 Tone::Working,
448 format!("Running {}", facts.job.as_deref().unwrap_or("a job")),
449 some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)),
450 None,
451 vec![VIEW_RUNNERS],
452 ),
453 Code::NoDocker => {
454 let app = match facts.platform.as_str() {
455 "windows" | "macos" => "Docker Desktop",
456 _ => "Docker",
457 };
458 (
459 Tone::Problem,
460 "Not running: Docker isn't running".into(),
461 some(format!("Running jobs {} needs Docker, and Docker isn't running on this computer, or isn't installed.", facts.backend)),
462 some(format!("Start {app}, or install it. The runner starts by itself once Docker answers.")),
463 if app == "Docker Desktop" { vec![OPEN_DOCKER, GET_DOCKER] } else { vec![GET_DOCKER] },
464 )
465 }
466 Code::NoLauncher => (
467 Tone::Warning,
468 "Not running: this g1t-runner has no sandbox".into(),
469 some("The g1t-runner g1t downloaded is older than its sandbox launcher, so it can't make a sandbox."),
470 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."),
471 vec![RETRY, CHOOSE_BACKEND],
472 ),
473 Code::NoWsl => (
474 Tone::Problem,
475 "Not running: WSL isn't installed".into(),
476 some("Linux jobs run in a WSL distribution of the runner's own, and WSL isn't installed on this computer."),
477 some(format!("Run `{}` in an admin terminal, then restart Windows. It works on every edition of Windows 11, Home included.", launcher::WSL_INSTALL)),
478 vec![COPY_WSL_INSTALL, LEARN_MORE],
479 ),
480 Code::WslMirrored => (
481 Tone::Problem,
482 "Not running: WSL uses mirrored networking".into(),
483 some("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost, so the sandbox won't start."),
484 some("Remove networkingMode=mirrored from %USERPROFILE%\\.wslconfig, run `wsl --shutdown`, then Retry."),
485 vec![RETRY, LEARN_MORE],
486 ),
487 Code::NoVirtualization => (
488 Tone::Problem,
489 "Not running: virtualization is off".into(),
490 some("The sandbox is a small virtual machine, and this computer has virtualization turned off."),
491 some(if facts.platform == "windows" {
492 "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, and Virtual Machine Platform in Windows features; then restart."
493 } else {
494 "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, then restart."
495 }),
496 if facts.platform == "windows" { vec![WINDOWS_FEATURES, LEARN_MORE] } else { vec![RETRY, LEARN_MORE] },
497 ),
498 Code::NoVfkit => (
499 Tone::Problem,
500 "Not running: vfkit isn't installed".into(),
501 some("Linux jobs run in a small VM that vfkit runs with Apple's Virtualization framework, and vfkit isn't installed."),
502 some(format!("Run `{}` in Terminal (macOS 13 or later). The runner starts by itself once it's there.", launcher::VFKIT_INSTALL)),
503 vec![COPY_VFKIT_INSTALL, LEARN_MORE],
504 ),
505 Code::NoSysbox => (
506 Tone::Problem,
507 "Not running: no Sysbox or KVM".into(),
508 some("The sandbox on Linux is the runner's image under Sysbox, or a VM under QEMU with KVM, and this computer has neither."),
509 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."),
510 vec![INSTALL_SYSBOX, CHOOSE_BACKEND],
511 ),
512 Code::WaitingForImage => {
513 let (release, image) = detail.as_deref().and_then(launcher::missing_images).map_or((None, None), |(release, image)| (Some(release), image));
514 let release = release.map_or_else(|| "The newest g1t-runner".to_owned(), |release| format!("g1t-runner {release}"));
515 (
516 Tone::Warning,
517 "Waiting for the sandbox's image".into(),
518 some(match image {
519 Some(image) => format!("{release} is published without the sandbox's image for this kind of computer ({image}) yet, so Linux jobs can't start."),
520 None => format!("{release} is published without the sandbox's images yet, so Linux jobs can't start."),
521 }),
522 some("g1t checks again every few minutes and starts Linux jobs once they're out. There's nothing for you to do."),
523 vec![CHECK_NOW],
524 )
525 }
526 Code::ImageFailed => {
527 let said = detail.clone().unwrap_or_default().to_ascii_lowercase();
528 if said.contains("sha-256") || said.contains("signature") {
529 (
530 Tone::Problem,
531 "Not running: the sandbox's image didn't check out".into(),
532 some("The sandbox's image that downloaded isn't the one g1t's release signed, so it wasn't used."),
533 some(format!(
534 "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.",
535 facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(),
536 facts.server
537 )),
538 vec![RETRY, REPORT],
539 )
540 } else {
541 (
542 Tone::Problem,
543 "Not running: the sandbox's image didn't download".into(),
544 some(match &detail {
545 Some(detail) => format!("The sandbox's image couldn't be downloaded: {detail}"),
546 None => "The sandbox's image couldn't be downloaded.".into(),
547 }),
548 some(format!(
549 "g1t tries again by itself{}. Check that this computer can reach {}; Retry tries now.",
550 facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(),
551 facts.server
552 )),
553 vec![RETRY, VIEW_LOG],
554 )
555 }
556 }
557 Code::SandboxUnavailable => (
558 Tone::Problem,
559 "Not running: the sandbox can't run here".into(),
560 detail.or_else(|| some("The sandbox can't be made on this computer.")),
561 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.")),
562 vec![RETRY, LEARN_MORE, CHOOSE_BACKEND],
563 ),
564 Code::SandboxFailed => (
565 Tone::Problem,
566 "Not running: the sandbox failed".into(),
567 detail.or_else(|| some("The sandbox stopped, and didn't say why.")),
568 some(match facts.wait_seconds {
569 Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Retry starts it now.", said_wait(wait)),
570 None => "Retry starts it again; its log says why it stopped.".into(),
571 }),
572 vec![RETRY, VIEW_LOG],
573 ),
574 Code::SandboxTaken => (
575 Tone::Warning,
576 "Not running: this computer has another sandbox".into(),
577 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."),
578 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."),
579 vec![REMOVE_SANDBOX, LEARN_MORE],
580 ),
581 Code::Updating => (Tone::Working, "Updating".into(), detail, some("The runner starts again as soon as it's done."), vec![]),
582 Code::UpdateFailed => (
583 Tone::Warning,
584 "Update failed".into(),
585 detail,
586 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."),
587 vec![CHECK_AGAIN, REPORT],
588 ),
589 Code::UpdateRefused => {
590 let why = detail.unwrap_or_else(|| "its signature didn't check out".into());
591 let tampered = format!(
592 "If this keeps happening, something between this computer and {} is changing downloads, such as a proxy or antivirus: report it.",
593 facts.server
594 );
595 match facts.update.installed.as_deref() {
596 Some(version) => (
597 Tone::Warning,
598 "Update refused: not signed by g1t".into(),
599 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)),
600 some(format!("The runner keeps running g1t-runner {version}, and g1t checks again within the hour. {tampered}")),
601 vec![CHECK_AGAIN, REPORT],
602 ),
603 None => (
604 Tone::Problem,
605 "Not running: the runner's release isn't signed by g1t".into(),
606 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)),
607 some(format!("g1t tries again by itself in a few minutes; Retry tries now. {tampered}")),
608 vec![RETRY, REPORT],
609 ),
610 }
611 }
612 Code::Offline => (
613 Tone::Warning,
614 format!("Offline: {} unreachable", facts.server),
615 some(format!("This computer can't reach {}, so the runner can't take jobs.", facts.server)),
616 some("Check the connection. The runner carries on by itself when it's back."),
617 vec![RETRY],
618 ),
619 Code::Crashed => (
620 Tone::Problem,
621 match facts.exit_code {
622 Some(exit) => format!("Runner stopped (exit code {exit})"),
623 None => "Runner stopped".into(),
624 },
625 detail,
626 Some(match facts.wait_seconds {
627 Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Restart starts it now.", said_wait(wait)),
628 None => "Restart starts it again; its log says why it stopped.".into(),
629 }),
630 vec![RESTART, VIEW_LOG],
631 ),
632 };
633 let capabilities = facts.sandbox.as_ref().map(|status| capabilities(status, facts)).unwrap_or_default();
634 let in_capabilities = !capabilities.is_empty()
635 && match code {
636 Code::Idle | Code::Busy | Code::KindsStopped => true,
637 Code::Starting | Code::Updating => facts.live,
638 // Said by a card, for its kind of job, word for word.
639 Code::NoWsl
640 | Code::WslMirrored
641 | Code::NoVirtualization
642 | Code::NoVfkit
643 | Code::NoSysbox
644 | Code::WaitingForImage
645 | Code::ImageFailed
646 | Code::SandboxUnavailable
647 | Code::SandboxFailed => cause.is_some() && capabilities.iter().any(|c| c.cause == cause),
648 _ => false,
649 };
650 let update = Some(update_line(facts, code));
651 Diagnosis { code, tone, title, cause, fix, actions, capabilities, in_capabilities, update }
652}
653
654/// What the app calls a sandbox's problem with Linux jobs, from the
655/// launcher's sentence about it, which goes with it as its detail.
656pub fn sandbox_code(why: Why) -> Code {
657 match why {
658 Why::NoWsl => Code::NoWsl,
659 Why::WslMirrored => Code::WslMirrored,
660 Why::NoVirtualization => Code::NoVirtualization,
661 Why::NoVfkit => Code::NoVfkit,
662 Why::NoSysboxOrKvm => Code::NoSysbox,
663 Why::NoImages => Code::WaitingForImage,
664 Why::Image => Code::ImageFailed,
665 _ => Code::SandboxFailed,
666 }
667}
668
669/// The code for what is wrong with Linux jobs, as a status says it, and
670/// the launcher's own words for it.
671pub fn sandbox_trouble(status: &Status) -> Option<(Code, String, Option<String>)> {
672 let (cause, fix) = status.trouble()?;
673 let code = match launcher::why(&cause) {
674 Why::Other if !status.linux().available => Code::SandboxUnavailable,
675 why => sandbox_code(why),
676 };
677 Some((code, cause, fix))
678}
679
680/// Each kind of job the sandbox runs, as a card of its own.
681fn capabilities(status: &Status, facts: &Facts) -> Vec<CapabilityLine> {
682 let listed = if status.capabilities.is_empty() { vec![status.linux()] } else { status.capabilities.clone() };
683 listed.iter().map(|capability| capability_line(status, capability, facts)).collect()
684}
685
686/// `Linux jobs`.
687pub fn kind_label(id: &str) -> String {
688 match id {
689 "linux" => "Linux jobs".to_owned(),
690 "windows" => "Windows jobs".to_owned(),
691 "macos" => "macOS jobs".to_owned(),
692 other => format!("{other} jobs"),
693 }
694}
695
696/// What a kind of job is, in a line, by how the sandbox makes it.
697fn about(id: &str, backend: Option<&str>) -> String {
698 match (id, backend) {
699 ("linux", Some("sysbox")) => "The runner under Sysbox, each job in a fresh container".into(),
700 ("linux", _) => "A Linux VM of its own, each job in a fresh container".into(),
701 ("windows", _) => "Each job in a Windows Sandbox, discarded after".into(),
702 ("macos", _) => "Each job in a macOS VM of its own, discarded after".into(),
703 _ => "Each job sandboxed".into(),
704 }
705}
706
707/// A catalogue title as a card's headline: `Not running: virtualization is off` is `Virtualization is off`.
708fn headline(title: &str) -> String {
709 let said = title.strip_prefix("Not running: ").unwrap_or(title);
710 let mut letters = said.chars();
711 letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default()
712}
713
714fn capability_line(status: &Status, capability: &launcher::Capability, facts: &Facts) -> CapabilityLine {
715 let id = capability.id.as_str();
716 let label = kind_label(id);
717 let enabled = !facts.off.iter().any(|off| off == id);
718 let per_kind = status.per_kind();
719 let mut line = CapabilityLine {
720 id: id.to_owned(),
721 label: label.clone(),
722 tone: Tone::Off,
723 state: KindState::Stopped,
724 title: "Stopped".into(),
725 about: about(id, status.backend.as_deref()),
726 headline: None,
727 cause: None,
728 fix: None,
729 actions: vec![],
730 enabled,
731 available: capability.available,
732 // Nothing to start or stop where it can't run, nor while the whole runner is
733 // stopped, whose own Start starts it (one the person stopped can be started).
734 can_toggle: per_kind && (capability.available || !enabled) && !(facts.held && enabled),
735 job: None,
736 progress: None,
737 isolation: capability.isolation.clone().filter(|_| capability.available),
738 runner: capability.available.then(|| format!("{}-{id}", facts.machine)),
739 version: capability.runner_version.clone(),
740 update: waiting(&facts.update, id, capability.job.is_some()),
741 };
742 let said = |line: &mut CapabilityLine, state: KindState, tone: Tone, cause: Option<String>, fix: Option<String>, actions: Vec<Action>| {
743 line.state = state;
744 line.tone = tone;
745 line.title = match state {
746 KindState::Busy => "Running a job",
747 KindState::Idle => "Waiting for jobs",
748 KindState::Starting => "Starting",
749 KindState::Downloading => "Downloading image",
750 KindState::Updating => "Updating",
751 KindState::Waiting => "Waiting for image",
752 KindState::Stopped => "Stopped",
753 KindState::Unavailable => "Not available",
754 KindState::Problem => "Problem",
755 }
756 .into();
757 line.cause = cause;
758 line.fix = fix;
759 line.actions = actions;
760 };
761 let again = |what: &str| format!("g1t {what} again by itself in a few minutes; Retry {what} now.");
762 let starting = facts.starting.iter().any(|kind| kind == id) || matches!(capability.state, State::Starting);
763
764 // Stopped by the person: nothing is wrong with it.
765 if capability.off() || (!enabled && per_kind) {
766 said(&mut line, KindState::Stopped, Tone::Off, some(format!("You stopped {label} on this computer. They stay stopped until you start them.")), None, vec![]);
767 return line;
768 }
769 // This computer can't run it, and why.
770 if !capability.available {
771 if id == "linux"
772 && let Some((code, cause, fix)) = sandbox_trouble(status)
773 {
774 let whole = diagnose(code, &Facts { detail: Some(cause), fix, sandbox: None, ..facts.clone() });
775 said(&mut line, KindState::Unavailable, Tone::Warning, whole.cause, whole.fix, whole.actions);
776 line.headline = Some(headline(&whole.title));
777 return line;
778 }
779 let cause = capability.cause.clone();
780 let (headline, cause, fix, actions) = match cause.as_deref().map(launcher::why) {
781 Some(Why::WindowsSandboxOff) => (
782 "Windows Sandbox is off",
783 some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't."),
784 some("On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox in Windows features, then restart."),
785 vec![WINDOWS_FEATURES],
786 ),
787 Some(Why::NoWsb) => (
788 "Windows needs an update",
789 some("This Windows has Windows Sandbox, but not its wsb command, which came with Windows 11 24H2."),
790 some("Update Windows to 24H2 or later, then restart g1t."),
791 vec![],
792 ),
793 Some(Why::MacNotReleased) => (
794 "Not released yet",
795 some("Sandboxed macOS jobs aren't released yet."),
796 some("They come with a g1t-runner update, which g1t downloads by itself. There's nothing for you to do."),
797 vec![],
798 ),
799 _ => ("Can't run here", cause, capability.fix.clone(), vec![LEARN_MORE]),
800 };
801 said(&mut line, KindState::Unavailable, Tone::Off, cause, fix, actions);
802 line.headline = Some(headline.into());
803 return line;
804 }
805 // Linux jobs that should run and don't, in the catalogue's words.
806 if id == "linux"
807 && !starting
808 && !matches!(capability.state, State::Updating)
809 && let Some((code, cause, fix)) = sandbox_trouble(status)
810 {
811 let wait_seconds = if facts.live { None } else { facts.wait_seconds };
812 let whole = diagnose(code, &Facts { detail: Some(cause), fix, wait_seconds, sandbox: None, ..facts.clone() });
813 let state = if code == Code::WaitingForImage { KindState::Waiting } else { KindState::Problem };
814 said(&mut line, state, whole.tone, whole.cause, whole.fix, whole.actions);
815 line.headline = Some(headline(&whole.title));
816 return line;
817 }
818 // A native kind of job whose runner didn't start, in the launcher's words.
819 if id != "linux" && capability.state == State::Error && !starting {
820 let cause = capability.cause.clone().or_else(|| some("Its runner didn't start, and didn't say why."));
821 said(&mut line, KindState::Problem, Tone::Problem, cause, some(again("tries")), vec![RETRY, VIEW_LOG]);
822 line.headline = Some("Its runner didn't start".into());
823 return line;
824 }
825 let downloading = facts.download.as_ref().filter(|_| id == "linux");
826 match capability.state {
827 _ if starting || capability.state == State::Updating => match downloading {
828 Some(download) => {
829 said(&mut line, KindState::Downloading, Tone::Working, some("Downloading and checking the sandbox's image, once: it takes a few minutes."), None, vec![]);
830 line.progress = Some(download.percent);
831 }
832 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![]),
833 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![]),
834 None => said(&mut line, KindState::Starting, Tone::Working, None, None, vec![]),
835 },
836 State::Running => match &capability.job {
837 Some(job) => {
838 said(&mut line, KindState::Busy, Tone::Working, None, None, vec![]);
839 // Every job a runner takes is on the repository's Actions page:
840 // workflow jobs, and the queue, merge check and checks tasks
841 // the runner's wire still calls `agent` work.
842 let link = (!job.repo.is_empty() && !facts.site.is_empty()).then(|| format!("{}/{}/actions", facts.site.trim_end_matches('/'), job.repo));
843 line.job = Some(JobLine { kind: job.kind.clone(), name: job.name.clone(), repo: job.repo.clone(), link });
844 }
845 None => said(&mut line, KindState::Idle, Tone::Ok, None, None, vec![]),
846 },
847 // Up while the others run, yet not running: it stopped by itself,
848 // or is to be registered.
849 State::Stopped | State::Absent if facts.live && status.running() => {
850 if capability.state == State::Absent {
851 said(&mut line, KindState::Starting, Tone::Working, some(format!("Registering it with {}.", facts.workspace.as_deref().unwrap_or("your workspace"))), None, vec![]);
852 } else {
853 said(&mut line, KindState::Problem, Tone::Problem, some("Its runner stopped by itself."), some(again("starts it")), vec![RETRY, VIEW_LOG]);
854 line.headline = Some("Its runner stopped".into());
855 }
856 }
857 State::Stopped | State::Absent => said(&mut line, KindState::Stopped, Tone::Off, None, None, vec![]),
858 State::Error | State::Unknown | State::Starting | State::Updating => {
859 said(&mut line, KindState::Problem, Tone::Problem, capability.cause.clone().or_else(|| status.error.clone()), capability.fix.clone(), vec![RETRY, VIEW_LOG]);
860 }
861 }
862 // The person stopped it, and this launcher can't stop one kind alone yet.
863 if !enabled && line.fix.is_none() {
864 line.fix = some(format!("This g1t-runner can't stop {label} on their own; the next one can, and g1t downloads it by itself."));
865 }
866 line
867}
868
869/// The runner in a few words, for the chip beside the title bar's settings
870/// button, which opens the Runner window: `Runner running`, `Running 2 jobs`.
871#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
872pub struct Chip {
873 pub tone: Tone,
874 pub label: String,
875}
876
877/// The chip, from the whole runner's diagnosis and its kinds of job: what
878/// it is doing, and whether anything there wants the person.
879pub fn chip(diagnosis: &Diagnosis) -> Chip {
880 let jobs = diagnosis.capabilities.iter().filter(|kind| kind.job.is_some()).count().max(usize::from(diagnosis.code == Code::Busy));
881 let wanting = diagnosis.capabilities.iter().any(|kind| kind.enabled && matches!(kind.tone, Tone::Problem | Tone::Warning));
882 let (tone, label) = match diagnosis.code {
883 _ if jobs > 1 => (Tone::Working, format!("Running {jobs} jobs")),
884 _ if jobs == 1 => (Tone::Working, "Running a job".to_owned()),
885 Code::Idle => (if wanting { Tone::Warning } else { Tone::Ok }, "Runner running".to_owned()),
886 Code::Starting | Code::Registering => (Tone::Working, "Runner starting".to_owned()),
887 Code::Updating => (Tone::Working, "Runner updating".to_owned()),
888 Code::UpdateFailed | Code::UpdateRefused => (Tone::Warning, "Runner running".to_owned()),
889 Code::Off => (Tone::Off, "Runner off".to_owned()),
890 Code::Stopped | Code::KindsStopped => (Tone::Off, "Runner stopped".to_owned()),
891 Code::Offline => (Tone::Warning, "Runner offline".to_owned()),
892 _ => (diagnosis.tone, "Runner needs you".to_owned()),
893 };
894 Chip { tone, label }
895}
896
897/// What the site says of this computer while g1t is closed on it.
898pub fn closed(facts: &Facts) -> Diagnosis {
899 Diagnosis {
900 code: Code::Off,
901 tone: Tone::Off,
902 title: format!("g1t is closed on {}", facts.machine),
903 cause: some(format!("This runner runs while g1t is open on {}.", facts.machine)),
904 fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)),
905 actions: vec![],
906 capabilities: vec![],
907 in_capabilities: false,
908 update: None,
909 }
910}
911
912#[cfg(test)]
913mod tests {
914 use super::*;
915
916 fn facts() -> Facts {
917 Facts {
918 workspace: Some("acme".into()),
919 backend: "sandboxed",
920 machine: "build-box".into(),
921 server: "g1t.sh".into(),
922 site: "https://g1t.sh".into(),
923 platform: "windows".into(),
924 ..Facts::default()
925 }
926 }
927
928 #[test]
929 fn a_fix_that_changes_the_runner_waits_for_its_status() {
930 for action in [START, TURN_ON, RETRY, CHECK_NOW, CHECK_UPDATES, CHECK_AGAIN, REMOVE_SANDBOX, RESTART, OPEN_DOCKER] {
931 assert!(action.waits, "{} waits for the status that follows", action.id);
932 }
933 for action in [SIGN_IN, CHOOSE_WORKSPACE, CHOOSE_BACKEND, GET_DOCKER, COPY_WSL_INSTALL, LEARN_MORE, VIEW_LOG, VIEW_RUNNERS] {
934 assert!(!action.waits, "{} is done once the app has it", action.id);
935 }
936 // Only what can't be undone asks first.
937 assert!(REMOVE_SANDBOX.confirm.is_some_and(|question| question.ends_with('.') && question.contains('?')));
938 assert!(RETRY.confirm.is_none());
939 let said = serde_json::to_value(RETRY).unwrap();
940 assert_eq!(said, serde_json::json!({ "id": "retry", "label": "Retry", "waits": true }));
941 }
942
943 #[test]
944 fn every_failure_says_why_and_how_to_fix_it() {
945 for code in [
946 Code::SignedOut,
947 Code::NoWorkspace,
948 Code::NotAllowed,
949 Code::RegisterFailed,
950 Code::NoDocker,
951 Code::NoLauncher,
952 Code::NoWsl,
953 Code::WslMirrored,
954 Code::NoVirtualization,
955 Code::NoVfkit,
956 Code::NoSysbox,
957 Code::WaitingForImage,
958 Code::ImageFailed,
959 Code::SandboxUnavailable,
960 Code::SandboxFailed,
961 Code::SandboxTaken,
962 Code::UpdateFailed,
963 Code::UpdateRefused,
964 Code::Offline,
965 Code::Crashed,
966 ] {
967 let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() });
968 assert!(diagnosis.cause.is_some(), "{code:?} has no cause");
969 assert!(diagnosis.fix.is_some(), "{code:?} has no fix");
970 assert!(!diagnosis.actions.is_empty(), "{code:?} has no action");
971 assert!(matches!(diagnosis.tone, Tone::Warning | Tone::Problem), "{code:?}");
972 // No fix sends the person off to check their connection or report it
973 // when that isn't what is wrong.
974 let fix = diagnosis.fix.unwrap();
975 if !matches!(code, Code::Offline | Code::ImageFailed) {
976 assert!(!fix.contains("check the connection") && !fix.contains("Check the connection"), "{code:?}: {fix}");
977 }
978 }
979 }
980
981 #[test]
982 fn the_words_name_the_workspace_the_machine_and_the_job() {
983 let not_allowed = diagnose(Code::NotAllowed, &facts());
984 assert_eq!(not_allowed.cause.as_deref(), Some("You can't add runners to acme: only its owners can."));
985 assert_eq!(not_allowed.actions[0].id, "choose_workspace");
986 let busy = diagnose(Code::Busy, &Facts { job: Some("Build (acme/web)".into()), ..facts() });
987 assert_eq!(busy.title, "Running Build (acme/web)");
988 assert_eq!(busy.cause.as_deref(), Some("Running sandboxed for acme on build-box."));
989 let crashed = diagnose(Code::Crashed, &Facts { exit_code: Some(101), wait_seconds: Some(30), ..facts() });
990 assert_eq!(crashed.title, "Runner stopped (exit code 101)");
991 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."));
992 assert_eq!(crashed.actions.iter().map(|a| a.id).collect::<Vec<_>>(), ["restart", "view_log"]);
993 assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker");
994 assert_eq!(closed(&facts()).title, "g1t is closed on build-box");
995 assert_eq!(said_wait(300), "5 minutes");
996 assert_eq!(said_wait(45), "45 seconds");
997 }
998
999 fn status(json: &str) -> Status {
1000 launcher::parse(json).unwrap()
1001 }
1002
1003 const NO_WSL_PRO: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
1004 "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
1005 "capabilities":[
1006 {"id":"linux","available":false,"state":"error","isolation":"nested-container",
1007 "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
1008 "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."},
1009 {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.2.1","job":null}]}"#;
1010
1011 #[test]
1012 fn without_wsl_the_fix_is_the_command_to_copy_and_windows_jobs_still_show() {
1013 let status = status(NO_WSL_PRO);
1014 let (code, cause, _) = sandbox_trouble(&status).unwrap();
1015 assert_eq!(code, Code::NoWsl);
1016 let diagnosis = diagnose(code, &Facts { detail: Some(cause), sandbox: Some(status), live: true, ..facts() });
1017 assert_eq!(diagnosis.title, "Not running: WSL isn't installed");
1018 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."));
1019 assert_eq!(diagnosis.actions[0], COPY_WSL_INSTALL);
1020 let [linux, windows] = &diagnosis.capabilities[..] else { panic!("{:?}", diagnosis.capabilities) };
1021 assert_eq!((linux.label.as_str(), linux.title.as_str(), linux.state), ("Linux jobs", "Not available", KindState::Unavailable));
1022 assert_eq!(linux.headline.as_deref(), Some("WSL isn't installed"));
1023 assert_eq!(linux.fix, diagnosis.fix);
1024 assert_eq!(linux.actions[0].id, "copy_wsl_install");
1025 assert_eq!((windows.label.as_str(), windows.title.as_str(), windows.tone), ("Windows jobs", "Waiting for jobs", Tone::Ok));
1026 assert_eq!(windows.runner.as_deref(), Some("build-box-windows"));
1027 assert_eq!(windows.about, "Each job in a Windows Sandbox, discarded after");
1028 // The Linux card says it all: the whole runner's line isn't shown as well.
1029 assert!(diagnosis.in_capabilities);
1030 // An older launcher can't stop one kind of job alone.
1031 assert!(!windows.can_toggle);
1032 }
1033
1034 #[test]
1035 fn on_home_windows_jobs_are_unavailable_and_linux_jobs_still_run() {
1036 let status = status(
1037 r#"{"schema":1,"state":"running","backend":"wsl2","runner_version":"0.3.0","launcher_version":"0.3.0",
1038 "job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"},
1039 "capabilities":[
1040 {"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"}},
1041 {"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…"}]}"#,
1042 );
1043 assert_eq!(sandbox_trouble(&status), None);
1044 let busy = diagnose(Code::Busy, &Facts { job: status.job().map(launcher::Job::said), sandbox: Some(status), live: true, ..facts() });
1045 assert_eq!(busy.title, "Running build (acme/web)");
1046 assert!(busy.in_capabilities);
1047 let [linux, windows] = &busy.capabilities[..] else { panic!() };
1048 assert_eq!((linux.title.as_str(), linux.state, linux.tone), ("Running a job", KindState::Busy, Tone::Working));
1049 let job = linux.job.as_ref().unwrap();
1050 assert_eq!((job.name.as_str(), job.repo.as_str(), job.link.as_deref()), ("build", "acme/web", Some("https://g1t.sh/acme/web/actions")));
1051 assert!(linux.can_toggle && linux.enabled);
1052 assert_eq!((windows.title.as_str(), windows.tone, windows.state), ("Not available", Tone::Off, KindState::Unavailable));
1053 assert_eq!(windows.headline.as_deref(), Some("Windows Sandbox is off"));
1054 assert_eq!(windows.cause.as_deref(), Some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't."));
1055 assert_eq!(windows.actions[0].id, "windows_features");
1056 // Nothing to start or stop where it can't run.
1057 assert!(!windows.can_toggle);
1058 assert_eq!(windows.runner, None);
1059 }
1060
1061 /// 0.3.0 was published without the sandbox's images: Linux jobs wait for
1062 /// them, plainly, while Windows jobs run.
1063 const NO_IMAGES: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.3.0",
1064 "error":"Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images",
1065 "capabilities":[
1066 {"id":"linux","available":true,"state":"error","isolation":"nested-container","turned_off":false},
1067 {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.3.0","turned_off":false}]}"#;
1068
1069 #[test]
1070 fn a_release_without_images_is_waited_for_not_reported() {
1071 let status = status(NO_IMAGES);
1072 let (code, cause, _) = sandbox_trouble(&status).unwrap();
1073 assert_eq!(code, Code::WaitingForImage);
1074 let whole = diagnose(code, &Facts { detail: Some(cause), ..facts() });
1075 assert_eq!(whole.title, "Waiting for the sandbox's image");
1076 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."));
1077 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."));
1078 assert_eq!(whole.actions, [CHECK_NOW]);
1079 assert!(!whole.fix.unwrap().contains("report"));
1080 // Windows jobs run: the runner waits for jobs, and the Linux card says what it waits for.
1081 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status), live: true, ..facts() });
1082 assert!(idle.in_capabilities);
1083 let [linux, windows] = &idle.capabilities[..] else { panic!() };
1084 assert_eq!((linux.state, linux.title.as_str(), linux.tone), (KindState::Waiting, "Waiting for image", Tone::Warning));
1085 assert_eq!(linux.headline.as_deref(), Some("Waiting for the sandbox's image"));
1086 assert_eq!(windows.state, KindState::Idle);
1087 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() });
1088 assert_eq!(image.title, "Not running: the sandbox's image didn't check out");
1089 let fetch = diagnose(Code::ImageFailed, &Facts { detail: Some("could not fetch https://g1t.sh/x: dns error".into()), wait_seconds: Some(60), ..facts() });
1090 assert_eq!(fetch.cause.as_deref(), Some("The sandbox's image couldn't be downloaded: could not fetch https://g1t.sh/x: dns error"));
1091 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."));
1092 }
1093
1094 #[test]
1095 fn a_kind_the_person_stopped_says_so_and_is_not_failing() {
1096 let status = status(
1097 r#"{"schema":1,"state":"running","backend":"wsl2","capabilities":[
1098 {"id":"linux","available":true,"state":"running","turned_off":false},
1099 {"id":"windows","available":true,"state":"stopped","turned_off":true}]}"#,
1100 );
1101 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status.clone()), live: true, off: vec!["windows".into()], ..facts() });
1102 let [linux, windows] = &idle.capabilities[..] else { panic!() };
1103 assert_eq!(linux.state, KindState::Idle);
1104 assert_eq!((windows.state, windows.tone, windows.enabled), (KindState::Stopped, Tone::Off, false));
1105 assert_eq!(windows.cause.as_deref(), Some("You stopped Windows jobs on this computer. They stay stopped until you start them."));
1106 assert!(windows.can_toggle && windows.actions.is_empty());
1107 // Stopped by itself while Linux jobs run is a problem, with Retry.
1108 let died = launcher::parse(&serde_json::to_string(&serde_json::json!({"schema":1,"state":"running","capabilities":[
1109 {"id":"linux","available":true,"state":"running","turned_off":false},
1110 {"id":"windows","available":true,"state":"stopped","turned_off":false}]})).unwrap()).unwrap();
1111 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(died.clone()), live: true, ..facts() });
1112 assert_eq!((idle.capabilities[1].state, idle.capabilities[1].actions[0].id), (KindState::Problem, "retry"));
1113 // With the whole runner stopped, it is only stopped.
1114 let stopped = diagnose(Code::Stopped, &Facts { sandbox: Some(died), live: false, held: true, ..facts() });
1115 // Its own Start or Stop waits for the runner's: the line above the cards starts it.
1116 assert!(!stopped.capabilities[0].can_toggle);
1117 assert_eq!(stopped.capabilities[1].state, KindState::Stopped);
1118 assert!(!stopped.in_capabilities);
1119 }
1120
1121 #[test]
1122 fn a_download_shows_how_far_it_is() {
1123 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}]}"#);
1124 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() };
1125 let starting = diagnose(Code::Starting, &facts);
1126 assert!(starting.in_capabilities);
1127 let [linux, windows] = &starting.capabilities[..] else { panic!() };
1128 assert_eq!((linux.state, linux.title.as_str(), linux.progress), (KindState::Downloading, "Downloading image", Some(40)));
1129 assert_eq!((windows.state, windows.progress), (KindState::Starting, None));
1130 }
1131
1132 #[test]
1133 fn each_launcher_trouble_has_its_words() {
1134 let trouble = |linux_available: bool, text: &str| {
1135 let json = serde_json::json!({
1136 "schema": 1, "state": "error", "error": text,
1137 "capabilities": [{"id": "linux", "available": linux_available, "state": "error", "cause": if linux_available { None } else { Some(text) }, "fix": "the launcher's fix"}]
1138 });
1139 sandbox_trouble(&status(&json.to_string())).unwrap()
1140 };
1141 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);
1142 assert_eq!(trouble(false, "vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.").0, Code::NoVfkit);
1143 assert_eq!(trouble(false, "This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.").0, Code::NoSysbox);
1144 assert_eq!(trouble(false, "WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.").0, Code::WslMirrored);
1145 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);
1146 assert_eq!(trouble(true, "release 0.3.0 has no sandbox images").0, Code::WaitingForImage);
1147 // Unknown words: shown as the launcher wrote them, its fix with them.
1148 let (code, cause, fix) = trouble(false, "This user may not use /dev/kvm.");
1149 assert_eq!(code, Code::SandboxUnavailable);
1150 let unavailable = diagnose(code, &Facts { detail: Some(cause), fix, ..facts() });
1151 assert_eq!(unavailable.cause.as_deref(), Some("This user may not use /dev/kvm."));
1152 assert_eq!(unavailable.fix.as_deref(), Some("the launcher's fix"));
1153 let (code, cause, _) = trouble(true, "the sandbox did not start within 600 seconds");
1154 assert_eq!(code, Code::SandboxFailed);
1155 let failed = diagnose(code, &Facts { detail: Some(cause), wait_seconds: Some(60), ..facts() });
1156 assert_eq!(failed.cause.as_deref(), Some("the sandbox did not start within 600 seconds"));
1157 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."));
1158 assert_eq!(diagnose(Code::NoVirtualization, &Facts { platform: "macos".into(), ..facts() }).actions[0].id, "retry");
1159 assert_eq!(diagnose(Code::NoVfkit, &facts()).actions[0].id, "copy_vfkit_install");
1160 }
1161
1162 #[test]
1163 fn when_g1t_last_looked_for_a_runner_is_said_plainly() {
1164 let ago = |seconds: u64| checked(&Update { checked_ago: Some(seconds), ..Update::default() });
1165 assert_eq!(checked(&Update::default()), "Not checked yet");
1166 assert_eq!(checked(&Update { checking: true, checked_ago: Some(30), ..Update::default() }), "Checking for updates…");
1167 assert_eq!(ago(20), "Checked just now");
1168 assert_eq!(ago(12 * 60 + 5), "Checked 12 min ago");
1169 assert_eq!(ago(3600), "Checked 1 hour ago");
1170 assert_eq!(ago(5 * 3600), "Checked 5 hours ago");
1171 assert_eq!(ago(2 * 86_400), "Checked 2 days ago");
1172 }
1173
1174 /// 0.3.2 is downloaded: Linux jobs take its image after the job they
1175 /// are running, Windows jobs, idle, take its program now.
1176 #[test]
1177 fn a_waiting_update_is_said_on_each_kind_of_jobs_card() {
1178 let status = status(
1179 r#"{"schema":1,"state":"running","backend":"wsl2","image_version":"0.3.1","capabilities":[
1180 {"id":"linux","available":true,"state":"running","runner_version":"0.3.1","turned_off":false,"job":{"kind":"workflow","id":"j","name":"build","repo":"acme/web"}},
1181 {"id":"windows","available":true,"state":"running","runner_version":"0.3.1","turned_off":false}]}"#,
1182 );
1183 let update = Update {
1184 installed: Some("0.3.1".into()),
1185 checked_at: Some("2026-10-12T12:00:00Z".into()),
1186 checked_ago: Some(12 * 60),
1187 ready: Some("0.3.2".into()),
1188 waiting: vec!["linux".into(), "windows".into()],
1189 ..Update::default()
1190 };
1191 let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status.clone()), live: true, update: update.clone(), ..facts() });
1192 let [linux, windows] = &busy.capabilities[..] else { panic!() };
1193 assert_eq!(linux.version.as_deref(), Some("0.3.1"));
1194 assert_eq!(linux.update.as_deref(), Some("0.3.2 ready, installs after this job"));
1195 assert_eq!(windows.update.as_deref(), Some("0.3.2 ready, installs in a moment"));
1196 let line = busy.update.unwrap();
1197 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")));
1198 // The cards say it, each for its own kind: nothing is said for the whole runner.
1199 assert_eq!(line.note, None);
1200 assert_eq!((line.check.id, line.check.label), ("check_updates", "Check for runner updates"));
1201 // Windows jobs stop while the program is replaced: installing, then nothing waits.
1202 let installing = Update { installing: vec!["windows".into()], ..update.clone() };
1203 let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status.clone()), live: true, update: installing, ..facts() });
1204 assert_eq!(busy.capabilities[1].update.as_deref(), Some("Installing 0.3.2"));
1205 let done = Update { waiting: vec!["linux".into()], ..update.clone() };
1206 let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status), live: true, update: done, ..facts() });
1207 assert_eq!(busy.capabilities[1].update, None);
1208 // In Docker or on this computer there are no cards: the whole runner says it.
1209 let docker = Update { waiting: vec!["runner".into()], ..update };
1210 let busy = diagnose(Code::Busy, &Facts { backend: "in Docker", job: Some("build (acme/web)".into()), update: docker.clone(), ..facts() });
1211 assert_eq!(busy.update.unwrap().note.as_deref(), Some("0.3.2 ready, installs after this job"));
1212 let idle = diagnose(Code::Idle, &Facts { backend: "in Docker", update: docker, ..facts() });
1213 assert_eq!(idle.update.unwrap().note.as_deref(), Some("0.3.2 ready, installs in a moment"));
1214 // What is said once g1t is closed knows nothing of updates.
1215 assert_eq!(closed(&facts()).update, None);
1216 }
1217
1218 #[test]
1219 fn a_release_not_signed_by_g1t_is_refused_and_said_so() {
1220 let detail = Some("the release's signature isn't g1t's".to_owned());
1221 let running = diagnose(Code::UpdateRefused, &Facts { detail: detail.clone(), update: Update { installed: Some("0.3.1".into()), ..Update::default() }, ..facts() });
1222 assert_eq!(running.title, "Update refused: not signed by g1t");
1223 assert_eq!(
1224 running.cause.as_deref(),
1225 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.")
1226 );
1227 assert!(running.fix.as_deref().unwrap().starts_with("The runner keeps running g1t-runner 0.3.1, and g1t checks again within the hour."));
1228 assert_eq!(running.actions, [CHECK_AGAIN, REPORT]);
1229 assert_eq!(running.tone, Tone::Warning);
1230 // With no runner yet, nothing runs until a signed one downloads.
1231 let nothing = diagnose(Code::UpdateRefused, &Facts { detail, ..facts() });
1232 assert_eq!(nothing.title, "Not running: the runner's release isn't signed by g1t");
1233 assert_eq!((nothing.tone, nothing.actions[0].id), (Tone::Problem, "retry"));
1234 assert!(!nothing.in_capabilities);
1235 }
1236
1237 #[test]
1238 fn the_title_bars_chip_says_the_runner_in_a_few_words() {
1239 let said = |code: Code, facts: &Facts| {
1240 let chip = chip(&diagnose(code, facts));
1241 (chip.tone, chip.label)
1242 };
1243 assert_eq!(said(Code::Idle, &facts()), (Tone::Ok, "Runner running".into()));
1244 assert_eq!(said(Code::Busy, &Facts { job: Some("build (acme/web)".into()), ..facts() }), (Tone::Working, "Running a job".into()));
1245 assert_eq!(said(Code::Starting, &facts()), (Tone::Working, "Runner starting".into()));
1246 assert_eq!(said(Code::Stopped, &facts()), (Tone::Off, "Runner stopped".into()));
1247 assert_eq!(said(Code::Off, &facts()), (Tone::Off, "Runner off".into()));
1248 assert_eq!(said(Code::NoWsl, &facts()), (Tone::Problem, "Runner needs you".into()));
1249 assert_eq!(said(Code::UpdateFailed, &Facts { detail: Some("x".into()), ..facts() }).1, "Runner running");
1250 // Both kinds of job busy at once.
1251 let both = status(
1252 r#"{"schema":1,"state":"running","capabilities":[
1253 {"id":"linux","available":true,"state":"running","turned_off":false,"job":{"kind":"workflow","id":"a","name":"build","repo":"acme/web"}},
1254 {"id":"windows","available":true,"state":"running","turned_off":false,"job":{"kind":"workflow","id":"b","name":"installer","repo":"acme/app"}}]}"#,
1255 );
1256 assert_eq!(said(Code::Busy, &Facts { sandbox: Some(both), live: true, ..facts() }), (Tone::Working, "Running 2 jobs".into()));
1257 // Linux jobs run while Windows jobs can't start: running, and wanting a look.
1258 let one_failing = status(NO_WSL_PRO);
1259 assert_eq!(said(Code::Idle, &Facts { sandbox: Some(one_failing), live: true, ..facts() }), (Tone::Warning, "Runner running".into()));
1260 }
1261
1262 #[test]
1263 fn a_diagnosis_without_the_sandbox_lists_no_capabilities() {
1264 let json = serde_json::to_value(diagnose(Code::Idle, &Facts { backend: "in Docker", ..facts() })).unwrap();
1265 assert!(json.get("capabilities").is_none());
1266 assert!(json.get("in_capabilities").is_none());
1267 let sandboxed = serde_json::to_value(diagnose(Code::Idle, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() })).unwrap();
1268 assert_eq!(sandboxed["capabilities"][1]["id"], "windows");
1269 assert_eq!(sandboxed["capabilities"][1]["state"], "idle");
1270 assert_eq!(sandboxed["in_capabilities"], true);
1271 // What affects everything is said once, above the cards.
1272 let signed_out = diagnose(Code::SignedOut, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() });
1273 assert!(!signed_out.in_capabilities);
1274 }
1275}