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