Skip to content
284 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
12use serde::Serialize;
13
14/// Every state the runner can be in.
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Code {
18 /// Running jobs is turned off in settings.
19 #[default]
20 Off,
21 /// The person pressed Stop.
22 Stopped,
23 Starting,
24 /// Waiting for the site to make a registration token.
25 Registering,
26 /// Nobody is signed in to g1t in the app.
27 SignedOut,
28 /// Signed in, owning no workspace: nowhere to register.
29 NoWorkspace,
30 /// The person may not add runners to the workspace.
31 NotAllowed,
32 RegisterFailed,
33 Idle,
34 Busy,
35 NoDocker,
36 NoSandbox,
37 NoSysbox,
38 Updating,
39 UpdateFailed,
40 Offline,
41 Crashed,
42}
43
44/// How a state should look: a dot's colour.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "snake_case")]
47pub enum Tone {
48 Ok,
49 Working,
50 Off,
51 Warning,
52 Problem,
53}
54
55/// A one-click fix.
56#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
57pub struct Action {
58 pub id: &'static str,
59 pub label: &'static str,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
63pub struct Diagnosis {
64 pub code: Code,
65 pub tone: Tone,
66 /// One line: the tray's, and the heading everywhere else.
67 pub title: String,
68 /// Why, in a sentence.
69 pub cause: Option<String>,
70 /// What to do about it.
71 pub fix: Option<String>,
72 pub actions: Vec<Action>,
73}
74
75/// What the words are filled in with.
76#[derive(Clone, Debug, Default)]
77pub struct Facts {
78 pub workspace: Option<String>,
79 /// How jobs run, as `sandboxed`, `in Docker`, `on this computer`.
80 pub backend: &'static str,
81 pub machine: String,
82 pub job: Option<String>,
83 pub detail: Option<String>,
84 pub exit_code: Option<i32>,
85 pub wait_seconds: Option<u64>,
86 /// The server's host: `g1t.sh`.
87 pub server: String,
88 pub platform: String,
89}
90
91const START: Action = Action { id: "start", label: "Start" };
92const TURN_ON: Action = Action { id: "start", label: "Turn on" };
93const RETRY: Action = Action { id: "retry", label: "Retry" };
94const RESTART: Action = Action { id: "restart", label: "Restart" };
95const SIGN_IN: Action = Action { id: "sign_in", label: "Sign in" };
96const CHOOSE_WORKSPACE: Action = Action { id: "choose_workspace", label: "Choose workspace" };
97const NEW_WORKSPACE: Action = Action { id: "new_workspace", label: "Create a workspace" };
98const CHOOSE_BACKEND: Action = Action { id: "choose_backend", label: "Choose how jobs run" };
99const OPEN_DOCKER: Action = Action { id: "open_docker", label: "Open Docker" };
100const GET_DOCKER: Action = Action { id: "get_docker", label: "Get Docker" };
101const INSTALL_SYSBOX: Action = Action { id: "install_sysbox", label: "Install Sysbox" };
102const LEARN_MORE: Action = Action { id: "learn_more", label: "Learn more" };
103const VIEW_LOG: Action = Action { id: "view_log", label: "View log" };
104const REPORT: Action = Action { id: "report", label: "Report" };
105const VIEW_RUNNERS: Action = Action { id: "view_runners", label: "View on g1t" };
106
107fn some(text: impl Into<String>) -> Option<String> {
108 Some(text.into())
109}
110
111/// The catalogue.
112pub fn diagnose(code: Code, facts: &Facts) -> Diagnosis {
113 let workspace = facts.workspace.as_deref().unwrap_or("your workspace");
114 let detail = facts.detail.clone();
115 let (tone, title, cause, fix, actions): (Tone, String, Option<String>, Option<String>, Vec<Action>) = match code {
116 Code::Off => (
117 Tone::Off,
118 "Not running: turned off".into(),
119 some("Running jobs on this computer is turned off in g1t's settings."),
120 some("Turn it on to run your workspace's self-hosted jobs here while g1t is open."),
121 vec![TURN_ON],
122 ),
123 Code::Stopped => (
124 Tone::Off,
125 "Not running: stopped".into(),
126 some("You stopped the runner. It starts again when you press Start, or when g1t next opens."),
127 None,
128 vec![START],
129 ),
130 Code::Starting => (Tone::Working, "Starting".into(), detail, None, vec![]),
131 Code::Registering => (
132 Tone::Working,
133 format!("Registering with {workspace}"),
134 some(format!("g1t is registering {} as a runner for {workspace}.", facts.machine)),
135 some("Keep g1t open and signed in; it takes a few seconds."),
136 vec![],
137 ),
138 Code::SignedOut => (
139 Tone::Warning,
140 "Not running: signed out".into(),
141 some("This computer isn't registered yet, and registering it needs you signed in to g1t."),
142 some("Sign in, as an owner of the workspace the runner is for."),
143 vec![SIGN_IN],
144 ),
145 Code::NoWorkspace => (
146 Tone::Warning,
147 "Not running: no workspace to run jobs for".into(),
148 some("Only a workspace's owners add runners to it, and you don't own one."),
149 some("Ask an owner of your workspace to run it, or create a workspace of your own."),
150 vec![NEW_WORKSPACE],
151 ),
152 Code::NotAllowed => (
153 Tone::Problem,
154 format!("Couldn't register with {workspace}"),
155 some(format!("You can't add runners to {workspace}: only its owners can.")),
156 some(format!("Ask an owner of {workspace} to make you one, or choose a workspace you own.")),
157 vec![CHOOSE_WORKSPACE],
158 ),
159 Code::RegisterFailed => (
160 Tone::Problem,
161 format!("Couldn't register with {workspace}"),
162 detail.or_else(|| some("g1t didn't make a registration for this computer.")),
163 some("It tries again by itself in a few minutes."),
164 vec![RETRY, VIEW_LOG],
165 ),
166 Code::Idle => (
167 Tone::Ok,
168 "Waiting for jobs".into(),
169 some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)),
170 None,
171 vec![VIEW_RUNNERS],
172 ),
173 Code::Busy => (
174 Tone::Working,
175 format!("Running {}", facts.job.as_deref().unwrap_or("a job")),
176 some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)),
177 None,
178 vec![VIEW_RUNNERS],
179 ),
180 Code::NoDocker => {
181 let app = match facts.platform.as_str() {
182 "windows" | "macos" => "Docker Desktop",
183 _ => "Docker",
184 };
185 (
186 Tone::Problem,
187 "Not running: Docker isn't running".into(),
188 some(format!("Running jobs {} needs Docker, and Docker isn't running on this computer, or isn't installed.", facts.backend)),
189 some(format!("Start {app}, or install it. The runner starts by itself once Docker answers.")),
190 if app == "Docker Desktop" { vec![OPEN_DOCKER, GET_DOCKER] } else { vec![GET_DOCKER] },
191 )
192 }
193 Code::NoSandbox => (
194 Tone::Warning,
195 "Not running: a sandbox isn't available on this computer yet".into(),
196 some("The sandbox keeps everything a job does away from this computer. It isn't built for this system yet."),
197 some("Wait for it, or choose to run jobs in Docker or directly on this computer. Neither is a sandbox: settings say what each can reach."),
198 vec![LEARN_MORE, CHOOSE_BACKEND],
199 ),
200 Code::NoSysbox => (
201 Tone::Problem,
202 "Not running: Sysbox isn't installed".into(),
203 some("The sandbox on Linux is the runner's image under Sysbox, and this computer's Docker doesn't have Sysbox."),
204 some("Install Sysbox; the runner starts by itself once Docker has it."),
205 vec![INSTALL_SYSBOX, CHOOSE_BACKEND],
206 ),
207 Code::Updating => (Tone::Working, "Updating".into(), detail, some("The runner starts again as soon as it's done."), vec![]),
208 Code::UpdateFailed => (
209 Tone::Warning,
210 "Update failed".into(),
211 detail,
212 some("The runner keeps running the version it has. Try again, or report it if it keeps happening."),
213 vec![RETRY, REPORT],
214 ),
215 Code::Offline => (
216 Tone::Warning,
217 format!("Offline: {} unreachable", facts.server),
218 some(format!("This computer can't reach {}, so the runner can't take jobs.", facts.server)),
219 some("Check the connection. The runner carries on by itself when it's back."),
220 vec![RETRY],
221 ),
222 Code::Crashed => (
223 Tone::Problem,
224 match facts.exit_code {
225 Some(exit) => format!("Runner stopped (exit code {exit})"),
226 None => "Runner stopped".into(),
227 },
228 detail,
229 Some(match facts.wait_seconds {
230 Some(wait) => format!("It starts again by itself in {wait} seconds. Restart now, or look at its log."),
231 None => "Restart it, or look at its log.".into(),
232 }),
233 vec![RESTART, VIEW_LOG],
234 ),
235 };
236 Diagnosis { code, tone, title, cause, fix, actions }
237}
238
239/// What the site says of this computer while g1t is closed on it.
240pub fn closed(facts: &Facts) -> Diagnosis {
241 Diagnosis {
242 code: Code::Off,
243 tone: Tone::Off,
244 title: format!("g1t is closed on {}", facts.machine),
245 cause: some(format!("This runner runs while g1t is open on {}.", facts.machine)),
246 fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)),
247 actions: vec![],
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 fn facts() -> Facts {
256 Facts { workspace: Some("acme".into()), backend: "sandboxed", machine: "build-box".into(), server: "g1t.sh".into(), platform: "windows".into(), ..Facts::default() }
257 }
258
259 #[test]
260 fn every_failure_says_why_and_how_to_fix_it() {
261 for code in [Code::SignedOut, Code::NoWorkspace, Code::NotAllowed, Code::RegisterFailed, Code::NoDocker, Code::NoSandbox, Code::NoSysbox, Code::UpdateFailed, Code::Offline, Code::Crashed] {
262 let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() });
263 assert!(diagnosis.cause.is_some(), "{code:?} has no cause");
264 assert!(diagnosis.fix.is_some(), "{code:?} has no fix");
265 assert!(!diagnosis.actions.is_empty(), "{code:?} has no action");
266 assert!(matches!(diagnosis.tone, Tone::Warning | Tone::Problem), "{code:?}");
267 }
268 }
269
270 #[test]
271 fn the_words_name_the_workspace_the_machine_and_the_job() {
272 let not_allowed = diagnose(Code::NotAllowed, &facts());
273 assert_eq!(not_allowed.cause.as_deref(), Some("You can't add runners to acme: only its owners can."));
274 assert_eq!(not_allowed.actions[0].id, "choose_workspace");
275 let busy = diagnose(Code::Busy, &Facts { job: Some("Build (acme/web)".into()), ..facts() });
276 assert_eq!(busy.title, "Running Build (acme/web)");
277 assert_eq!(busy.cause.as_deref(), Some("Running sandboxed for acme on build-box."));
278 let crashed = diagnose(Code::Crashed, &Facts { exit_code: Some(101), wait_seconds: Some(30), ..facts() });
279 assert_eq!(crashed.title, "Runner stopped (exit code 101)");
280 assert_eq!(crashed.actions.iter().map(|a| a.id).collect::<Vec<_>>(), ["restart", "view_log"]);
281 assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker");
282 assert_eq!(closed(&facts()).title, "g1t is closed on build-box");
283 }
284}