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