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