Skip to content

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

g1t.sh
1,227 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/// The runner in a few words, for the chip beside the title bar's settings
837/// button, which opens the Runner window: `Runner running`, `Running 2 jobs`.
838#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
839pub struct Chip {
840 pub tone: Tone,
841 pub label: String,
842}
843
844/// The chip, from the whole runner's diagnosis and its kinds of job: what
845/// it is doing, and whether anything there wants the person.
846pub fn chip(diagnosis: &Diagnosis) -> Chip {
847 let jobs = diagnosis.capabilities.iter().filter(|kind| kind.job.is_some()).count().max(usize::from(diagnosis.code == Code::Busy));
848 let wanting = diagnosis.capabilities.iter().any(|kind| kind.enabled && matches!(kind.tone, Tone::Problem | Tone::Warning));
849 let (tone, label) = match diagnosis.code {
850 _ if jobs > 1 => (Tone::Working, format!("Running {jobs} jobs")),
851 _ if jobs == 1 => (Tone::Working, "Running a job".to_owned()),
852 Code::Idle => (if wanting { Tone::Warning } else { Tone::Ok }, "Runner running".to_owned()),
853 Code::Starting | Code::Registering => (Tone::Working, "Runner starting".to_owned()),
854 Code::Updating => (Tone::Working, "Runner updating".to_owned()),
855 Code::UpdateFailed | Code::UpdateRefused => (Tone::Warning, "Runner running".to_owned()),
856 Code::Off => (Tone::Off, "Runner off".to_owned()),
857 Code::Stopped | Code::KindsStopped => (Tone::Off, "Runner stopped".to_owned()),
858 Code::Offline => (Tone::Warning, "Runner offline".to_owned()),
859 _ => (diagnosis.tone, "Runner needs you".to_owned()),
860 };
861 Chip { tone, label }
862}
863
864/// What the site says of this computer while g1t is closed on it.
865pub fn closed(facts: &Facts) -> Diagnosis {
866 Diagnosis {
867 code: Code::Off,
868 tone: Tone::Off,
869 title: format!("g1t is closed on {}", facts.machine),
870 cause: some(format!("This runner runs while g1t is open on {}.", facts.machine)),
871 fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)),
872 actions: vec![],
873 capabilities: vec![],
874 in_capabilities: false,
875 update: None,
876 }
877}
878
879#[cfg(test)]
880mod tests {
881 use super::*;
882
883 fn facts() -> Facts {
884 Facts {
885 workspace: Some("acme".into()),
886 backend: "sandboxed",
887 machine: "build-box".into(),
888 server: "g1t.sh".into(),
889 site: "https://g1t.sh".into(),
890 platform: "windows".into(),
891 ..Facts::default()
892 }
893 }
894
895 #[test]
896 fn every_failure_says_why_and_how_to_fix_it() {
897 for code in [
898 Code::SignedOut,
899 Code::NoWorkspace,
900 Code::NotAllowed,
901 Code::RegisterFailed,
902 Code::NoDocker,
903 Code::NoLauncher,
904 Code::NoWsl,
905 Code::WslMirrored,
906 Code::NoVirtualization,
907 Code::NoVfkit,
908 Code::NoSysbox,
909 Code::WaitingForImage,
910 Code::ImageFailed,
911 Code::SandboxUnavailable,
912 Code::SandboxFailed,
913 Code::SandboxTaken,
914 Code::UpdateFailed,
915 Code::UpdateRefused,
916 Code::Offline,
917 Code::Crashed,
918 ] {
919 let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() });
920 assert!(diagnosis.cause.is_some(), "{code:?} has no cause");
921 assert!(diagnosis.fix.is_some(), "{code:?} has no fix");
922 assert!(!diagnosis.actions.is_empty(), "{code:?} has no action");
923 assert!(matches!(diagnosis.tone, Tone::Warning | Tone::Problem), "{code:?}");
924 // No fix sends the person off to check their connection or report it
925 // when that isn't what is wrong.
926 let fix = diagnosis.fix.unwrap();
927 if !matches!(code, Code::Offline | Code::ImageFailed) {
928 assert!(!fix.contains("check the connection") && !fix.contains("Check the connection"), "{code:?}: {fix}");
929 }
930 }
931 }
932
933 #[test]
934 fn the_words_name_the_workspace_the_machine_and_the_job() {
935 let not_allowed = diagnose(Code::NotAllowed, &facts());
936 assert_eq!(not_allowed.cause.as_deref(), Some("You can't add runners to acme: only its owners can."));
937 assert_eq!(not_allowed.actions[0].id, "choose_workspace");
938 let busy = diagnose(Code::Busy, &Facts { job: Some("Build (acme/web)".into()), ..facts() });
939 assert_eq!(busy.title, "Running Build (acme/web)");
940 assert_eq!(busy.cause.as_deref(), Some("Running sandboxed for acme on build-box."));
941 let crashed = diagnose(Code::Crashed, &Facts { exit_code: Some(101), wait_seconds: Some(30), ..facts() });
942 assert_eq!(crashed.title, "Runner stopped (exit code 101)");
943 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."));
944 assert_eq!(crashed.actions.iter().map(|a| a.id).collect::<Vec<_>>(), ["restart", "view_log"]);
945 assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker");
946 assert_eq!(closed(&facts()).title, "g1t is closed on build-box");
947 assert_eq!(said_wait(300), "5 minutes");
948 assert_eq!(said_wait(45), "45 seconds");
949 }
950
951 fn status(json: &str) -> Status {
952 launcher::parse(json).unwrap()
953 }
954
955 const NO_WSL_PRO: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
956 "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
957 "capabilities":[
958 {"id":"linux","available":false,"state":"error","isolation":"nested-container",
959 "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
960 "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."},
961 {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.2.1","job":null}]}"#;
962
963 #[test]
964 fn without_wsl_the_fix_is_the_command_to_copy_and_windows_jobs_still_show() {
965 let status = status(NO_WSL_PRO);
966 let (code, cause, _) = sandbox_trouble(&status).unwrap();
967 assert_eq!(code, Code::NoWsl);
968 let diagnosis = diagnose(code, &Facts { detail: Some(cause), sandbox: Some(status), live: true, ..facts() });
969 assert_eq!(diagnosis.title, "Not running: WSL isn't installed");
970 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."));
971 assert_eq!(diagnosis.actions[0], COPY_WSL_INSTALL);
972 let [linux, windows] = &diagnosis.capabilities[..] else { panic!("{:?}", diagnosis.capabilities) };
973 assert_eq!((linux.label.as_str(), linux.title.as_str(), linux.state), ("Linux jobs", "Not available", KindState::Unavailable));
974 assert_eq!(linux.headline.as_deref(), Some("WSL isn't installed"));
975 assert_eq!(linux.fix, diagnosis.fix);
976 assert_eq!(linux.actions[0].id, "copy_wsl_install");
977 assert_eq!((windows.label.as_str(), windows.title.as_str(), windows.tone), ("Windows jobs", "Waiting for jobs", Tone::Ok));
978 assert_eq!(windows.runner.as_deref(), Some("build-box-windows"));
979 assert_eq!(windows.about, "Each job in a Windows Sandbox, discarded after");
980 // The Linux card says it all: the whole runner's line isn't shown as well.
981 assert!(diagnosis.in_capabilities);
982 // An older launcher can't stop one kind of job alone.
983 assert!(!windows.can_toggle);
984 }
985
986 #[test]
987 fn on_home_windows_jobs_are_unavailable_and_linux_jobs_still_run() {
988 let status = status(
989 r#"{"schema":1,"state":"running","backend":"wsl2","runner_version":"0.3.0","launcher_version":"0.3.0",
990 "job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"},
991 "capabilities":[
992 {"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"}},
993 {"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…"}]}"#,
994 );
995 assert_eq!(sandbox_trouble(&status), None);
996 let busy = diagnose(Code::Busy, &Facts { job: status.job().map(launcher::Job::said), sandbox: Some(status), live: true, ..facts() });
997 assert_eq!(busy.title, "Running build (acme/web)");
998 assert!(busy.in_capabilities);
999 let [linux, windows] = &busy.capabilities[..] else { panic!() };
1000 assert_eq!((linux.title.as_str(), linux.state, linux.tone), ("Running a job", KindState::Busy, Tone::Working));
1001 let job = linux.job.as_ref().unwrap();
1002 assert_eq!((job.name.as_str(), job.repo.as_str(), job.link.as_deref()), ("build", "acme/web", Some("https://g1t.sh/acme/web/actions")));
1003 assert!(linux.can_toggle && linux.enabled);
1004 assert_eq!((windows.title.as_str(), windows.tone, windows.state), ("Not available", Tone::Off, KindState::Unavailable));
1005 assert_eq!(windows.headline.as_deref(), Some("Windows Sandbox is off"));
1006 assert_eq!(windows.cause.as_deref(), Some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't."));
1007 assert_eq!(windows.actions[0].id, "windows_features");
1008 // Nothing to start or stop where it can't run.
1009 assert!(!windows.can_toggle);
1010 assert_eq!(windows.runner, None);
1011 }
1012
1013 /// 0.3.0 was published without the sandbox's images: Linux jobs wait for
1014 /// them, plainly, while Windows jobs run.
1015 const NO_IMAGES: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.3.0",
1016 "error":"Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images",
1017 "capabilities":[
1018 {"id":"linux","available":true,"state":"error","isolation":"nested-container","turned_off":false},
1019 {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.3.0","turned_off":false}]}"#;
1020
1021 #[test]
1022 fn a_release_without_images_is_waited_for_not_reported() {
1023 let status = status(NO_IMAGES);
1024 let (code, cause, _) = sandbox_trouble(&status).unwrap();
1025 assert_eq!(code, Code::WaitingForImage);
1026 let whole = diagnose(code, &Facts { detail: Some(cause), ..facts() });
1027 assert_eq!(whole.title, "Waiting for the sandbox's image");
1028 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."));
1029 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."));
1030 assert_eq!(whole.actions, [CHECK_NOW]);
1031 assert!(!whole.fix.unwrap().contains("report"));
1032 // Windows jobs run: the runner waits for jobs, and the Linux card says what it waits for.
1033 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status), live: true, ..facts() });
1034 assert!(idle.in_capabilities);
1035 let [linux, windows] = &idle.capabilities[..] else { panic!() };
1036 assert_eq!((linux.state, linux.title.as_str(), linux.tone), (KindState::Waiting, "Waiting for image", Tone::Warning));
1037 assert_eq!(linux.headline.as_deref(), Some("Waiting for the sandbox's image"));
1038 assert_eq!(windows.state, KindState::Idle);
1039 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() });
1040 assert_eq!(image.title, "Not running: the sandbox's image didn't check out");
1041 let fetch = diagnose(Code::ImageFailed, &Facts { detail: Some("could not fetch https://g1t.sh/x: dns error".into()), wait_seconds: Some(60), ..facts() });
1042 assert_eq!(fetch.cause.as_deref(), Some("The sandbox's image couldn't be downloaded: could not fetch https://g1t.sh/x: dns error"));
1043 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."));
1044 }
1045
1046 #[test]
1047 fn a_kind_the_person_stopped_says_so_and_is_not_failing() {
1048 let status = status(
1049 r#"{"schema":1,"state":"running","backend":"wsl2","capabilities":[
1050 {"id":"linux","available":true,"state":"running","turned_off":false},
1051 {"id":"windows","available":true,"state":"stopped","turned_off":true}]}"#,
1052 );
1053 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status.clone()), live: true, off: vec!["windows".into()], ..facts() });
1054 let [linux, windows] = &idle.capabilities[..] else { panic!() };
1055 assert_eq!(linux.state, KindState::Idle);
1056 assert_eq!((windows.state, windows.tone, windows.enabled), (KindState::Stopped, Tone::Off, false));
1057 assert_eq!(windows.cause.as_deref(), Some("You stopped Windows jobs on this computer. They stay stopped until you start them."));
1058 assert!(windows.can_toggle && windows.actions.is_empty());
1059 // Stopped by itself while Linux jobs run is a problem, with Retry.
1060 let died = launcher::parse(&serde_json::to_string(&serde_json::json!({"schema":1,"state":"running","capabilities":[
1061 {"id":"linux","available":true,"state":"running","turned_off":false},
1062 {"id":"windows","available":true,"state":"stopped","turned_off":false}]})).unwrap()).unwrap();
1063 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(died.clone()), live: true, ..facts() });
1064 assert_eq!((idle.capabilities[1].state, idle.capabilities[1].actions[0].id), (KindState::Problem, "retry"));
1065 // With the whole runner stopped, it is only stopped.
1066 let stopped = diagnose(Code::Stopped, &Facts { sandbox: Some(died), live: false, held: true, ..facts() });
1067 // Its own Start or Stop waits for the runner's: the line above the cards starts it.
1068 assert!(!stopped.capabilities[0].can_toggle);
1069 assert_eq!(stopped.capabilities[1].state, KindState::Stopped);
1070 assert!(!stopped.in_capabilities);
1071 }
1072
1073 #[test]
1074 fn a_download_shows_how_far_it_is() {
1075 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}]}"#);
1076 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() };
1077 let starting = diagnose(Code::Starting, &facts);
1078 assert!(starting.in_capabilities);
1079 let [linux, windows] = &starting.capabilities[..] else { panic!() };
1080 assert_eq!((linux.state, linux.title.as_str(), linux.progress), (KindState::Downloading, "Downloading image", Some(40)));
1081 assert_eq!((windows.state, windows.progress), (KindState::Starting, None));
1082 }
1083
1084 #[test]
1085 fn each_launcher_trouble_has_its_words() {
1086 let trouble = |linux_available: bool, text: &str| {
1087 let json = serde_json::json!({
1088 "schema": 1, "state": "error", "error": text,
1089 "capabilities": [{"id": "linux", "available": linux_available, "state": "error", "cause": if linux_available { None } else { Some(text) }, "fix": "the launcher's fix"}]
1090 });
1091 sandbox_trouble(&status(&json.to_string())).unwrap()
1092 };
1093 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);
1094 assert_eq!(trouble(false, "vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.").0, Code::NoVfkit);
1095 assert_eq!(trouble(false, "This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.").0, Code::NoSysbox);
1096 assert_eq!(trouble(false, "WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.").0, Code::WslMirrored);
1097 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);
1098 assert_eq!(trouble(true, "release 0.3.0 has no sandbox images").0, Code::WaitingForImage);
1099 // Unknown words: shown as the launcher wrote them, its fix with them.
1100 let (code, cause, fix) = trouble(false, "This user may not use /dev/kvm.");
1101 assert_eq!(code, Code::SandboxUnavailable);
1102 let unavailable = diagnose(code, &Facts { detail: Some(cause), fix, ..facts() });
1103 assert_eq!(unavailable.cause.as_deref(), Some("This user may not use /dev/kvm."));
1104 assert_eq!(unavailable.fix.as_deref(), Some("the launcher's fix"));
1105 let (code, cause, _) = trouble(true, "the sandbox did not start within 600 seconds");
1106 assert_eq!(code, Code::SandboxFailed);
1107 let failed = diagnose(code, &Facts { detail: Some(cause), wait_seconds: Some(60), ..facts() });
1108 assert_eq!(failed.cause.as_deref(), Some("the sandbox did not start within 600 seconds"));
1109 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."));
1110 assert_eq!(diagnose(Code::NoVirtualization, &Facts { platform: "macos".into(), ..facts() }).actions[0].id, "retry");
1111 assert_eq!(diagnose(Code::NoVfkit, &facts()).actions[0].id, "copy_vfkit_install");
1112 }
1113
1114 #[test]
1115 fn when_g1t_last_looked_for_a_runner_is_said_plainly() {
1116 let ago = |seconds: u64| checked(&Update { checked_ago: Some(seconds), ..Update::default() });
1117 assert_eq!(checked(&Update::default()), "Not checked yet");
1118 assert_eq!(checked(&Update { checking: true, checked_ago: Some(30), ..Update::default() }), "Checking for updates…");
1119 assert_eq!(ago(20), "Checked just now");
1120 assert_eq!(ago(12 * 60 + 5), "Checked 12 min ago");
1121 assert_eq!(ago(3600), "Checked 1 hour ago");
1122 assert_eq!(ago(5 * 3600), "Checked 5 hours ago");
1123 assert_eq!(ago(2 * 86_400), "Checked 2 days ago");
1124 }
1125
1126 /// 0.3.2 is downloaded: Linux jobs take its image after the job they
1127 /// are running, Windows jobs, idle, take its program now.
1128 #[test]
1129 fn a_waiting_update_is_said_on_each_kind_of_jobs_card() {
1130 let status = status(
1131 r#"{"schema":1,"state":"running","backend":"wsl2","image_version":"0.3.1","capabilities":[
1132 {"id":"linux","available":true,"state":"running","runner_version":"0.3.1","turned_off":false,"job":{"kind":"workflow","id":"j","name":"build","repo":"acme/web"}},
1133 {"id":"windows","available":true,"state":"running","runner_version":"0.3.1","turned_off":false}]}"#,
1134 );
1135 let update = Update {
1136 installed: Some("0.3.1".into()),
1137 checked_at: Some("2026-10-12T12:00:00Z".into()),
1138 checked_ago: Some(12 * 60),
1139 ready: Some("0.3.2".into()),
1140 waiting: vec!["linux".into(), "windows".into()],
1141 ..Update::default()
1142 };
1143 let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status.clone()), live: true, update: update.clone(), ..facts() });
1144 let [linux, windows] = &busy.capabilities[..] else { panic!() };
1145 assert_eq!(linux.version.as_deref(), Some("0.3.1"));
1146 assert_eq!(linux.update.as_deref(), Some("0.3.2 ready, installs after this job"));
1147 assert_eq!(windows.update.as_deref(), Some("0.3.2 ready, installs in a moment"));
1148 let line = busy.update.unwrap();
1149 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")));
1150 // The cards say it, each for its own kind: nothing is said for the whole runner.
1151 assert_eq!(line.note, None);
1152 assert_eq!((line.check.id, line.check.label), ("check_updates", "Check for runner updates"));
1153 // Windows jobs stop while the program is replaced: installing, then nothing waits.
1154 let installing = Update { installing: vec!["windows".into()], ..update.clone() };
1155 let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status.clone()), live: true, update: installing, ..facts() });
1156 assert_eq!(busy.capabilities[1].update.as_deref(), Some("Installing 0.3.2"));
1157 let done = Update { waiting: vec!["linux".into()], ..update.clone() };
1158 let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status), live: true, update: done, ..facts() });
1159 assert_eq!(busy.capabilities[1].update, None);
1160 // In Docker or on this computer there are no cards: the whole runner says it.
1161 let docker = Update { waiting: vec!["runner".into()], ..update };
1162 let busy = diagnose(Code::Busy, &Facts { backend: "in Docker", job: Some("build (acme/web)".into()), update: docker.clone(), ..facts() });
1163 assert_eq!(busy.update.unwrap().note.as_deref(), Some("0.3.2 ready, installs after this job"));
1164 let idle = diagnose(Code::Idle, &Facts { backend: "in Docker", update: docker, ..facts() });
1165 assert_eq!(idle.update.unwrap().note.as_deref(), Some("0.3.2 ready, installs in a moment"));
1166 // What is said once g1t is closed knows nothing of updates.
1167 assert_eq!(closed(&facts()).update, None);
1168 }
1169
1170 #[test]
1171 fn a_release_not_signed_by_g1t_is_refused_and_said_so() {
1172 let detail = Some("the release's signature isn't g1t's".to_owned());
1173 let running = diagnose(Code::UpdateRefused, &Facts { detail: detail.clone(), update: Update { installed: Some("0.3.1".into()), ..Update::default() }, ..facts() });
1174 assert_eq!(running.title, "Update refused: not signed by g1t");
1175 assert_eq!(
1176 running.cause.as_deref(),
1177 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.")
1178 );
1179 assert!(running.fix.as_deref().unwrap().starts_with("The runner keeps running g1t-runner 0.3.1, and g1t checks again within the hour."));
1180 assert_eq!(running.actions, [CHECK_AGAIN, REPORT]);
1181 assert_eq!(running.tone, Tone::Warning);
1182 // With no runner yet, nothing runs until a signed one downloads.
1183 let nothing = diagnose(Code::UpdateRefused, &Facts { detail, ..facts() });
1184 assert_eq!(nothing.title, "Not running: the runner's release isn't signed by g1t");
1185 assert_eq!((nothing.tone, nothing.actions[0].id), (Tone::Problem, "retry"));
1186 assert!(!nothing.in_capabilities);
1187 }
1188
1189 #[test]
1190 fn the_title_bars_chip_says_the_runner_in_a_few_words() {
1191 let said = |code: Code, facts: &Facts| {
1192 let chip = chip(&diagnose(code, facts));
1193 (chip.tone, chip.label)
1194 };
1195 assert_eq!(said(Code::Idle, &facts()), (Tone::Ok, "Runner running".into()));
1196 assert_eq!(said(Code::Busy, &Facts { job: Some("build (acme/web)".into()), ..facts() }), (Tone::Working, "Running a job".into()));
1197 assert_eq!(said(Code::Starting, &facts()), (Tone::Working, "Runner starting".into()));
1198 assert_eq!(said(Code::Stopped, &facts()), (Tone::Off, "Runner stopped".into()));
1199 assert_eq!(said(Code::Off, &facts()), (Tone::Off, "Runner off".into()));
1200 assert_eq!(said(Code::NoWsl, &facts()), (Tone::Problem, "Runner needs you".into()));
1201 assert_eq!(said(Code::UpdateFailed, &Facts { detail: Some("x".into()), ..facts() }).1, "Runner running");
1202 // Both kinds of job busy at once.
1203 let both = status(
1204 r#"{"schema":1,"state":"running","capabilities":[
1205 {"id":"linux","available":true,"state":"running","turned_off":false,"job":{"kind":"workflow","id":"a","name":"build","repo":"acme/web"}},
1206 {"id":"windows","available":true,"state":"running","turned_off":false,"job":{"kind":"workflow","id":"b","name":"installer","repo":"acme/app"}}]}"#,
1207 );
1208 assert_eq!(said(Code::Busy, &Facts { sandbox: Some(both), live: true, ..facts() }), (Tone::Working, "Running 2 jobs".into()));
1209 // Linux jobs run while Windows jobs can't start: running, and wanting a look.
1210 let one_failing = status(NO_WSL_PRO);
1211 assert_eq!(said(Code::Idle, &Facts { sandbox: Some(one_failing), live: true, ..facts() }), (Tone::Warning, "Runner running".into()));
1212 }
1213
1214 #[test]
1215 fn a_diagnosis_without_the_sandbox_lists_no_capabilities() {
1216 let json = serde_json::to_value(diagnose(Code::Idle, &Facts { backend: "in Docker", ..facts() })).unwrap();
1217 assert!(json.get("capabilities").is_none());
1218 assert!(json.get("in_capabilities").is_none());
1219 let sandboxed = serde_json::to_value(diagnose(Code::Idle, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() })).unwrap();
1220 assert_eq!(sandboxed["capabilities"][1]["id"], "windows");
1221 assert_eq!(sandboxed["capabilities"][1]["state"], "idle");
1222 assert_eq!(sandboxed["in_capabilities"], true);
1223 // What affects everything is said once, above the cards.
1224 let signed_out = diagnose(Code::SignedOut, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() });
1225 assert!(!signed_out.in_capabilities);
1226 }
1227}