Skip to content

Commit

The desktop app runs jobs sandboxed on Windows, macOS and Linux. Its Sandboxed choice, the default, is now g1t-runner's sandbox launcher on every computer, where on Windows and macOS it said a sandbox isn't available on this computer yet and ran nothing. A few seconds after g1t opens, the app runs `g1t-runner sandbox up --json` with its own copy of the runner and its state in the app's data folder, its URL, the computer's name and the `desktop` label (the launcher adds `-linux` and `-windows` and the OS labels itself), and the registration token from the site the first time only: the launcher keeps the registration after that, across updates too, and a workspace's sandbox is remembered (sandbox-registration.json), so choosing Sandboxed again after another way of running jobs needs no new token. Linux jobs run in a Linux VM of the runner's own (WSL 2 on Windows, vfkit on macOS, Sysbox or QEMU with KVM on Linux), and on Windows 11 Pro and up Windows jobs run each in a Windows Sandbox. While it runs, the app asks `sandbox status --json` every eight seconds, on a thread of its own, for the tray, settings and the site. Quitting or Stop runs `sandbox down`; choosing another way of running jobs waits for the job and runs `down --drain`; moving to another workspace removes the sandbox (`down --delete`) and makes it again. A new runner release is put in place while nothing runs, and the sandbox then takes the release's image with `sandbox update`; every six hours, `update` also runs on an idle sandbox, which does nothing when its image is the newest.

The launcher's status is read in one place (runner/launcher.rs): typed, schema 1 checked, any field it adds ignored, and found after the log lines `up` prints before it. Why the sandbox can't run, in the launcher's sentences, is said in the catalogue's own words with a fix and its buttons (runner/diagnosis.rs): WSL not installed, with Copy command for `wsl --install --no-distribution` in an admin terminal; WSL's mirrored networking; virtualization off, with Open Windows features; vfkit missing, with Copy command for `brew install vfkit`; no Sysbox or KVM on Linux; the sandbox's image not downloaded or not the release's; and anything else in the launcher's words, its fix with it. A token g1t refused, a sandbox with no registration, or a runner g1t no longer knows registers again by itself. Each kind of job has a line of its own, Linux jobs and Windows jobs, in settings and in the Desktops section of the site's Runners page: on Windows Home, Windows jobs are Not available, Windows Sandbox is off or the edition has none, and Linux jobs still run. The report's diagnosis carries these lines (`capabilities`, in the contracts and kept by report_desktop with the diagnosis it belongs to). A sandbox someone made by hand with `g1t-runner sandbox up` is left alone: the app says this computer has another sandbox and how to remove it. A `g1t-runner` from before the launcher is replaced by a newer release, or said plainly. Nothing ever falls back to Docker or to running jobs directly on the computer. The Linux container the app ran itself (runner/container.rs) is gone: the launcher runs it under Sysbox there now. The desktop guide says how the sandbox runs, what each computer needs for it, the commands the app runs and when, and every state with its fix; What's not there yet no longer lists the sandbox.

syntaqxcommitted Parent595e374Browse files
17 files+1803−4320/17 viewed
+21−0
261261 .diagnosis-text .buttons:empty {
262262 display: none;
263263 }
264+/* With the sandbox, each kind of job it runs: Linux jobs, Windows jobs. */
265+.capabilities {
266+ display: flex;
267+ flex-direction: column;
268+ gap: 0.5rem;
269+ margin: 0.5rem 0 0;
270+ padding: 0.5rem 0 0;
271+ border-top: 1px solid var(--line);
272+ list-style: none;
273+}
274+.capabilities[hidden] {
275+ display: none;
276+}
277+.capabilities li {
278+ display: flex;
279+ align-items: flex-start;
280+ gap: 0.5rem;
281+}
282+.capabilities .dot {
283+ margin-top: 0.3rem;
284+}
264285 .dot {
265286 flex: none;
266287 width: 8px;
+2−0
6161 <span class="muted" id="runner-cause"></span>
6262 <span id="runner-fix"></span>
6363 <span class="buttons" id="runner-actions"></span>
64+ <!-- Sandboxed: each kind of job it runs, Linux and Windows, on its own line. -->
65+ <ul class="capabilities" id="runner-capabilities" hidden></ul>
6466 </span>
6567 <button type="button" id="runner-toggle">Stop</button>
6668 </div>
+55−11
9393 return item;
9494 }
9595
96+ // A fix's button. Choosing is done here, in this window; a copy says it was copied.
97+ function actionButton(action, primary) {
98+ var button = document.createElement("button");
99+ button.type = "button";
100+ button.textContent = action.label;
101+ if (primary) button.className = "primary";
102+ button.addEventListener("click", function () {
103+ if (action.id === "choose_workspace") return byId("runnerWorkspace").focus();
104+ if (action.id === "choose_backend") return byId("runnerBackend").focus();
105+ invoke("runner_control", { action: action.id }).then(function () {
106+ if (action.id.indexOf("copy_") !== 0) return;
107+ button.textContent = "Copied";
108+ setTimeout(function () {
109+ button.textContent = action.label;
110+ }, 2000);
111+ }, fail);
112+ });
113+ return button;
114+ }
115+
116+ function span(className, text) {
117+ var item = document.createElement("span");
118+ if (className) item.className = className;
119+ item.textContent = text;
120+ return item;
121+ }
122+
123+ // Each kind of job the sandbox runs, when there is more than one: Linux
124+ // jobs can run while Windows jobs cannot.
125+ function renderCapabilities(capabilities) {
126+ var list = byId("runner-capabilities");
127+ list.textContent = "";
128+ list.hidden = !capabilities || capabilities.length < 2;
129+ if (list.hidden) return;
130+ capabilities.forEach(function (capability) {
131+ var item = document.createElement("li");
132+ item.appendChild(span("dot " + capability.tone, ""));
133+ var text = span("diagnosis-text", "");
134+ var title = document.createElement("strong");
135+ title.textContent = capability.label + ": " + capability.title;
136+ text.appendChild(title);
137+ if (capability.cause) text.appendChild(span("muted", capability.cause));
138+ if (capability.fix) text.appendChild(span("", capability.fix));
139+ var buttons = span("buttons", "");
140+ capability.actions.forEach(function (action) {
141+ buttons.appendChild(actionButton(action, false));
142+ });
143+ text.appendChild(buttons);
144+ item.appendChild(text);
145+ list.appendChild(item);
146+ });
147+ }
148+
96149 function renderRunner(runner) {
97150 if (!runner) return;
98151 var diagnosis = runner.diagnosis;
103156 var actions = byId("runner-actions");
104157 actions.textContent = "";
105158 diagnosis.actions.forEach(function (action, at) {
106− var button = document.createElement("button");
107− button.type = "button";
108− button.textContent = action.label;
109− if (at === 0) button.className = "primary";
110− button.addEventListener("click", function () {
111− // Choosing is done here, in this window.
112− if (action.id === "choose_workspace") return byId("runnerWorkspace").focus();
113− if (action.id === "choose_backend") return byId("runnerBackend").focus();
114− invoke("runner_control", { action: action.id }).catch(fail);
115− });
116− actions.appendChild(button);
159+ actions.appendChild(actionButton(action, at === 0));
117160 });
161+ renderCapabilities(diagnosis.capabilities);
118162 byId("runner").checked = !!runner.enabled;
119163 var on = runner.enabled && !runner.paused;
120164 byId("runner-toggle").textContent = on ? "Stop" : "Start";
+6−35
33 //!
44 //! | Backend | How | What a job can reach |
55 //! | --- | --- | --- |
6−//! | Sandboxed (the default) | The runner in a sandbox of its own: its image under Sysbox on Linux today (container.rs), a VM that `g1t-runner sandbox` makes on Windows and macOS once it is built (launcher.rs). Jobs are containers inside it: `nested-container` isolation. | Its own container. Not this computer. |
6+//! | Sandboxed (the default) | `g1t-runner sandbox` (launcher.rs): Linux jobs in a Linux VM of the runner's own (WSL 2 on Windows, vfkit on macOS, Sysbox or QEMU with KVM on Linux), each a container inside it, `nested-container`; on Windows 11 Pro and up, Windows jobs each in a Windows Sandbox, `vm`. What the computer lacks for it, the launcher says, and the app shows. | Its own container, or its own Windows Sandbox. Not this computer. |
77 //! | Docker | `g1t-runner` on this computer, driving this computer's Docker (native.rs): `container` isolation. | Its own container; but the runner controls this computer's Docker, which on Docker Desktop is a VM with the person's files shared into it. Not a sandbox. |
88 //! | On this computer | `g1t-runner --no-docker` (native.rs): `host` isolation. | Everything the person's account can. |
99 //!
1414 use std::process::{Command, Stdio};
1515
1616 use serde::{Deserialize, Serialize};
17−
18−use super::launcher;
1917
2018 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
2119 #[serde(rename_all = "snake_case")]
4038 /// What starts the runner for a backend, here.
4139 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
4240 pub enum Engine {
43− /// The runner's image under Sysbox, through this computer's Docker.
44− Sysbox,
45− /// `g1t-runner sandbox`, which makes and owns the sandbox.
41+ /// `g1t-runner sandbox`, which makes and owns the sandbox, and says
42+ /// itself what this computer lacks for one.
4643 Launcher,
4744 /// `g1t-runner` itself, as a child of the app; `docker` is whether its
4845 /// jobs run in this computer's Docker or directly on it.
5451 pub enum Missing {
5552 /// Docker is not installed, or not running.
5653 Docker,
57− /// No sandbox can be made on this computer yet.
58− Sandbox,
59− /// Linux, whose sandbox needs Sysbox in its Docker, which has none.
60− Sysbox,
6154 }
6255
6356 /// A program run without a console window flashing up on Windows.
8376 .is_ok_and(|status| status.success())
8477 }
8578
86−/// Whether this computer's Docker has Sysbox's runtime.
87−fn sysbox_ready() -> bool {
88− quiet("docker")
89− .args(["info", "--format", "{{json .Runtimes}}"])
90− .stderr(Stdio::null())
91− .output()
92− .is_ok_and(|out| out.status.success() && String::from_utf8_lossy(&out.stdout).contains("sysbox-runc"))
93−}
94−
9579 /// What a backend needs, checked now: how to start it, or why it cannot.
9680 pub fn engine(backend: Backend) -> Result<Engine, Missing> {
9781 match backend {
98− Backend::Sandboxed => {
99− if launcher::READY {
100− return Ok(Engine::Launcher);
101− }
102− if !cfg!(target_os = "linux") {
103− return Err(Missing::Sandbox);
104− }
105− if !docker_ready() {
106− return Err(Missing::Docker);
107− }
108− if !sysbox_ready() {
109− return Err(Missing::Sysbox);
110− }
111− Ok(Engine::Sysbox)
112− }
82+ Backend::Sandboxed => Ok(Engine::Launcher),
11383 Backend::Docker => {
11484 if docker_ready() {
11585 Ok(Engine::Native { docker: true })
156126 }
157127
158128 #[test]
159− fn on_this_computer_needs_nothing_more() {
129+ fn on_this_computer_needs_nothing_more_and_the_sandbox_is_the_launchers() {
160130 assert_eq!(engine(Backend::Host), Ok(Engine::Native { docker: false }));
131+ assert_eq!(engine(Backend::Sandboxed), Ok(Engine::Launcher));
161132 }
162133
163134 #[test]
+0−139
1−//! The Sandboxed backend on Linux today: the runner's image under Sysbox,
2−//! through this computer's Docker, exactly as docs.g1t.sh/guides/
3−//! self-hosted-runners/#run-it-as-a-container runs it. Sysbox gives the
4−//! runner's container a user namespace of its own and a Docker Engine
5−//! inside it, so each job is a container inside the runner's container and
6−//! neither reaches this computer (`nested-container` isolation). The
7−//! machine's Docker socket is never given to it.
8−//!
9−//! The registration and credential are kept on a volume of their own, so
10−//! the container can be replaced, when a newer image is out, without
11−//! registering again. Keeping it up to date is pulling the image and,
12−//! while the runner is idle, putting a new container in the old one's place.
13−
14−use std::process::{Child, Stdio};
15−
16−use super::backend::quiet;
17−
18−/// The runner's image, published with each release.
19−pub const IMAGE: &str = "g1t.sh/flagon-io/g1t-runner:latest";
20−/// The container the app runs, and the volume its registration is kept on.
21−pub const NAME: &str = "g1t-desktop-runner";
22−pub const VOLUME: &str = "g1t-desktop-runner";
23−
24−fn docker_ok(args: &[&str]) -> bool {
25− quiet("docker").args(args).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|status| status.success())
26−}
27−
28−fn docker_text(args: &[&str]) -> Option<String> {
29− let out = quiet("docker").args(args).stderr(Stdio::null()).output().ok()?;
30− out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()).filter(|text| !text.is_empty())
31−}
32−
33−/// The arguments of the `docker run` that starts the runner. Each
34−/// variable's value comes from the environment of the `docker` command,
35−/// never its command line, so a registration token is not in the
36−/// computer's list of processes.
37−pub fn run_args(names: &[&str]) -> Vec<String> {
38− let mut args: Vec<String> = ["run", "-d", "--name", NAME, "--runtime=sysbox-runc", "--label", "sh.g1t.desktop=runner", "-v"]
39− .iter()
40− .map(|s| s.to_string())
41− .collect();
42− args.push(format!("{VOLUME}:/data"));
43− for name in names {
44− args.push("-e".into());
45− args.push((*name).to_owned());
46− }
47− args.push(IMAGE.into());
48− args.push("register-and-run".into());
49− args
50−}
51−
52−/// Fetches the newest image; whether the one here changed.
53−pub fn pull() -> bool {
54− let before = docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]);
55− let pulled = docker_ok(&["pull", "--quiet", IMAGE]);
56− pulled && docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]) != before
57−}
58−
59−/// Whether the image is here at all.
60−pub fn have_image() -> bool {
61− docker_ok(&["image", "inspect", IMAGE])
62−}
63−
64−/// Whether the running container is from an older image than the one here.
65−pub fn outdated() -> bool {
66− let image = docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]);
67− let running = docker_text(&["inspect", "--format", "{{.Image}}", NAME]);
68− matches!((image, running), (Some(image), Some(running)) if image != running)
69−}
70−
71−/// Whether a registration is kept here.
72−pub fn registered() -> bool {
73− docker_ok(&["volume", "inspect", VOLUME])
74−}
75−
76−/// Starts the runner's container in place of any earlier one, then
77−/// follows its log: the child's output is the runner's.
78−pub fn start(envs: &[(&str, String)]) -> Result<Child, String> {
79− let _ = docker_ok(&["rm", "-f", NAME]);
80− let names: Vec<&str> = envs.iter().map(|(name, _)| *name).collect();
81− let out = quiet("docker")
82− .args(run_args(&names))
83− .envs(envs.iter().map(|(name, value)| (*name, value.as_str())))
84− .stdout(Stdio::null())
85− .output()
86− .map_err(|e| format!("could not run docker: {e}"))?;
87− if !out.status.success() {
88− return Err(String::from_utf8_lossy(&out.stderr).trim().to_owned());
89− }
90− quiet("docker")
91− .args(["logs", "--follow", NAME])
92− .stdout(Stdio::piped())
93− .stderr(Stdio::piped())
94− .spawn()
95− .map_err(|e| format!("could not follow the runner's log: {e}"))
96−}
97−
98−/// How the container's runner ended, while the container is still there.
99−pub fn exit_code() -> Option<i32> {
100− docker_text(&["inspect", "--format", "{{.State.ExitCode}}", NAME]).and_then(|code| code.parse().ok())
101−}
102−
103−/// Stops the container and removes it; its volume stays.
104−pub fn stop(logs: Option<&mut Child>) {
105− let _ = docker_ok(&["stop", "--time", "20", NAME]);
106− let _ = docker_ok(&["rm", "-f", NAME]);
107− if let Some(logs) = logs {
108− let _ = logs.kill();
109− let _ = logs.wait();
110− }
111−}
112−
113−/// Unregisters the runner kept on the volume, then removes the volume.
114−pub fn forget() {
115− if registered() {
116− let mut args = vec!["run", "--rm", "--runtime=sysbox-runc", "-v"];
117− let mount = format!("{VOLUME}:/data");
118− args.push(&mount);
119− args.extend([IMAGE, "remove"]);
120− let _ = docker_ok(&args);
121− let _ = docker_ok(&["volume", "rm", "-f", VOLUME]);
122− }
123−}
124−
125−#[cfg(test)]
126−mod tests {
127− use super::*;
128−
129− #[test]
130− fn the_runner_runs_under_sysbox_without_the_machines_docker() {
131− let args = run_args(&["G1T_RUNNER_URL", "G1T_RUNNER_TOKEN"]);
132− assert!(args.contains(&"--runtime=sysbox-runc".to_owned()));
133− assert!(args.contains(&"g1t-desktop-runner:/data".to_owned()));
134− assert!(!args.iter().any(|a| a.contains("docker.sock") || a == "--privileged"), "{args:?}");
135− // Values never on the command line: only the names.
136− assert!(args.windows(2).any(|w| w[0] == "-e" && w[1] == "G1T_RUNNER_TOKEN"));
137− assert_eq!(&args[args.len() - 2..], [IMAGE, "register-and-run"]);
138− }
139−}
+306−11
88 //!
99 //! Each action is an id the settings window and the site turn into a
1010 //! 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, …) are said in its words, with a fix and its
15+//! buttons; any other is shown as the launcher wrote it. With the sandbox,
16+//! each kind of job it runs (Linux jobs, Windows jobs) has a line of its
17+//! own as well (`capabilities`), since one can run while the other cannot.
1118
1219 use serde::Serialize;
1320
21+use super::launcher::{self, State, Status, Why};
22+
1423 /// Every state the runner can be in.
1524 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
1625 #[serde(rename_all = "snake_case")]
3342 Idle,
3443 Busy,
3544 NoDocker,
36− NoSandbox,
45+ /// The `g1t-runner` here is from before its sandbox launcher.
46+ NoLauncher,
47+ /// Windows, without WSL, which holds the sandbox's Linux VM.
48+ NoWsl,
49+ /// WSL set to mirrored networking, which reaches this computer's localhost.
50+ WslMirrored,
51+ /// The computer's virtualization is turned off.
52+ NoVirtualization,
53+ /// macOS, without vfkit, which runs the sandbox's VM.
54+ NoVfkit,
55+ /// Linux, with neither Sysbox in Docker nor KVM.
3756 NoSysbox,
57+ /// The sandbox's image couldn't be downloaded, or isn't the release's.
58+ ImageFailed,
59+ /// The sandbox can't be made here, for a reason in the launcher's words.
60+ SandboxUnavailable,
61+ /// The sandbox failed, in the launcher's words.
62+ SandboxFailed,
63+ /// The computer has a sandbox the app didn't make, and has room for one.
64+ SandboxTaken,
3865 Updating,
3966 UpdateFailed,
4067 Offline,
7097 /// What to do about it.
7198 pub fix: Option<String>,
7299 pub actions: Vec<Action>,
100+ /// With the sandbox: each kind of job it runs, on its own line.
101+ #[serde(skip_serializing_if = "Vec::is_empty")]
102+ pub capabilities: Vec<CapabilityLine>,
103+}
104+
105+/// One kind of job the sandbox runs, `linux` or `windows` (or `macos`),
106+/// and how it is: Linux jobs can run while Windows jobs cannot.
107+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
108+pub struct CapabilityLine {
109+ pub id: String,
110+ /// `Linux jobs`.
111+ pub label: String,
112+ pub tone: Tone,
113+ /// `Waiting for jobs`, `Not available`.
114+ pub title: String,
115+ pub cause: Option<String>,
116+ pub fix: Option<String>,
117+ pub actions: Vec<Action>,
73118 }
74119
75120 /// What the words are filled in with.
86131 /// The server's host: `g1t.sh`.
87132 pub server: String,
88133 pub platform: String,
134+ /// What the sandbox says to do, for a cause in its own words.
135+ pub fix: Option<String>,
136+ /// How the sandbox is, when jobs run sandboxed.
137+ pub sandbox: Option<Status>,
89138 }
90139
91140 const START: Action = Action { id: "start", label: "Start" };
99148 const OPEN_DOCKER: Action = Action { id: "open_docker", label: "Open Docker" };
100149 const GET_DOCKER: Action = Action { id: "get_docker", label: "Get Docker" };
101150 const INSTALL_SYSBOX: Action = Action { id: "install_sysbox", label: "Install Sysbox" };
151+const COPY_WSL_INSTALL: Action = Action { id: "copy_wsl_install", label: "Copy command" };
152+const COPY_VFKIT_INSTALL: Action = Action { id: "copy_vfkit_install", label: "Copy command" };
153+const WINDOWS_FEATURES: Action = Action { id: "windows_features", label: "Open Windows features" };
102154 const LEARN_MORE: Action = Action { id: "learn_more", label: "Learn more" };
103155 const VIEW_LOG: Action = Action { id: "view_log", label: "View log" };
104156 const REPORT: Action = Action { id: "report", label: "Report" };
190242 if app == "Docker Desktop" { vec![OPEN_DOCKER, GET_DOCKER] } else { vec![GET_DOCKER] },
191243 )
192244 }
193− Code::NoSandbox => (
245+ Code::NoLauncher => (
194246 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],
247+ "Not running: this g1t-runner has no sandbox".into(),
248+ some("The g1t-runner g1t downloaded is older than its sandbox launcher, so it can't make a sandbox."),
249+ some("g1t looks for a newer one by itself; 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."),
250+ vec![RETRY, CHOOSE_BACKEND],
251+ ),
252+ Code::NoWsl => (
253+ Tone::Problem,
254+ "Not running: WSL isn't installed".into(),
255+ some("Linux jobs run in a WSL distribution of the runner's own, and WSL isn't installed on this computer."),
256+ some(format!("Run `{}` in an admin terminal, then restart Windows. It works on every edition of Windows 11, Home included.", launcher::WSL_INSTALL)),
257+ vec![COPY_WSL_INSTALL, LEARN_MORE],
258+ ),
259+ Code::WslMirrored => (
260+ Tone::Problem,
261+ "Not running: WSL uses mirrored networking".into(),
262+ some("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost, so the sandbox won't start."),
263+ some("Remove networkingMode=mirrored from %USERPROFILE%\\.wslconfig, run `wsl --shutdown`, then Retry."),
264+ vec![RETRY, LEARN_MORE],
199265 ),
266+ Code::NoVirtualization => (
267+ Tone::Problem,
268+ "Not running: virtualization is off".into(),
269+ some("The sandbox is a small virtual machine, and this computer has virtualization turned off."),
270+ some(if facts.platform == "windows" {
271+ "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, and Virtual Machine Platform in Windows features; then restart."
272+ } else {
273+ "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, then restart."
274+ }),
275+ if facts.platform == "windows" { vec![WINDOWS_FEATURES, LEARN_MORE] } else { vec![RETRY, LEARN_MORE] },
276+ ),
277+ Code::NoVfkit => (
278+ Tone::Problem,
279+ "Not running: vfkit isn't installed".into(),
280+ some("Linux jobs run in a small VM that vfkit runs with Apple's Virtualization framework, and vfkit isn't installed."),
281+ some(format!("Run `{}` in Terminal (macOS 13 or later). The runner starts by itself once it's there.", launcher::VFKIT_INSTALL)),
282+ vec![COPY_VFKIT_INSTALL, LEARN_MORE],
283+ ),
200284 Code::NoSysbox => (
201285 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."),
286+ "Not running: no Sysbox or KVM".into(),
287+ some("The sandbox on Linux is the runner's image under Sysbox, or a VM under QEMU with KVM, and this computer has neither."),
288+ 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."),
205289 vec![INSTALL_SYSBOX, CHOOSE_BACKEND],
206290 ),
291+ Code::ImageFailed => (
292+ Tone::Problem,
293+ "Not running: the sandbox's image didn't download".into(),
294+ detail.or_else(|| some("The sandbox's image couldn't be downloaded, or wasn't the release's, so it wasn't used.")),
295+ some("It's only ever used once its signature and SHA-256 are the release's. g1t tries again by itself; check the connection, or report it if it keeps happening."),
296+ vec![RETRY, REPORT],
297+ ),
298+ Code::SandboxUnavailable => (
299+ Tone::Problem,
300+ "Not running: the sandbox can't run here".into(),
301+ detail.or_else(|| some("The sandbox can't be made on this computer.")),
302+ 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.")),
303+ vec![RETRY, LEARN_MORE, CHOOSE_BACKEND],
304+ ),
305+ Code::SandboxFailed => (
306+ Tone::Problem,
307+ "Not running: the sandbox failed".into(),
308+ detail.or_else(|| some("The sandbox stopped, and didn't say why.")),
309+ Some(match facts.wait_seconds {
310+ Some(wait) => format!("It starts again by itself in {wait} seconds. Retry now, or look at its log."),
311+ None => "Retry, or look at its log.".into(),
312+ }),
313+ vec![RETRY, VIEW_LOG],
314+ ),
315+ Code::SandboxTaken => (
316+ Tone::Warning,
317+ "Not running: this computer has another sandbox".into(),
318+ some("This computer already has a g1t-runner sandbox that g1t didn't make, such as one started with `g1t-runner sandbox up`, and it has room for one."),
319+ some("Remove it with `g1t-runner sandbox down --delete`, then Retry; its runners stay listed on g1t until you remove them there. Or keep it, and turn off running jobs in g1t."),
320+ vec![RETRY, LEARN_MORE],
321+ ),
207322 Code::Updating => (Tone::Working, "Updating".into(), detail, some("The runner starts again as soon as it's done."), vec![]),
208323 Code::UpdateFailed => (
209324 Tone::Warning,
233348 vec![RESTART, VIEW_LOG],
234349 ),
235350 };
236− Diagnosis { code, tone, title, cause, fix, actions }
351+ let capabilities = facts.sandbox.as_ref().map(|status| capabilities(status, facts)).unwrap_or_default();
352+ Diagnosis { code, tone, title, cause, fix, actions, capabilities }
237353 }
238354
355+/// What the app calls a sandbox's problem with Linux jobs, from the
356+/// launcher's sentence about it, which goes with it as its detail.
357+pub fn sandbox_code(why: Why) -> Code {
358+ match why {
359+ Why::NoWsl => Code::NoWsl,
360+ Why::WslMirrored => Code::WslMirrored,
361+ Why::NoVirtualization => Code::NoVirtualization,
362+ Why::NoVfkit => Code::NoVfkit,
363+ Why::NoSysboxOrKvm => Code::NoSysbox,
364+ Why::Image => Code::ImageFailed,
365+ _ => Code::SandboxFailed,
366+ }
367+}
368+
369+/// The code for what is wrong with Linux jobs, as a status says it, and
370+/// the launcher's own words for it.
371+pub fn sandbox_trouble(status: &Status) -> Option<(Code, String, Option<String>)> {
372+ let (cause, fix) = status.trouble()?;
373+ let code = match launcher::why(&cause) {
374+ Why::Other if !status.linux().available => Code::SandboxUnavailable,
375+ why => sandbox_code(why),
376+ };
377+ Some((code, cause, fix))
378+}
379+
380+/// Each kind of job the sandbox runs, on a line of its own.
381+fn capabilities(status: &Status, facts: &Facts) -> Vec<CapabilityLine> {
382+ let listed = if status.capabilities.is_empty() { vec![status.linux()] } else { status.capabilities.clone() };
383+ listed.iter().map(|capability| capability_line(status, capability, facts)).collect()
384+}
385+
386+fn capability_line(status: &Status, capability: &launcher::Capability, facts: &Facts) -> CapabilityLine {
387+ let label = match capability.id.as_str() {
388+ "linux" => "Linux jobs".to_owned(),
389+ "windows" => "Windows jobs".to_owned(),
390+ "macos" => "macOS jobs".to_owned(),
391+ other => format!("{other} jobs"),
392+ };
393+ let line = |tone: Tone, title: String, cause: Option<String>, fix: Option<String>, actions: Vec<Action>| CapabilityLine {
394+ id: capability.id.clone(),
395+ label: label.clone(),
396+ tone,
397+ title,
398+ cause,
399+ fix,
400+ actions,
401+ };
402+ let title = if capability.available { "Not running" } else { "Not available" };
403+ if capability.id == "linux" {
404+ if let Some((code, cause, fix)) = sandbox_trouble(status) {
405+ // The same words as the whole runner's when Linux jobs are what is wrong.
406+ let said = diagnose(code, &Facts { detail: Some(cause), fix, wait_seconds: None, sandbox: None, ..facts.clone() });
407+ return line(Tone::Problem, title.into(), said.cause, said.fix, said.actions);
408+ }
409+ } else if !capability.available || capability.state == State::Error {
410+ let cause = capability.cause.clone();
411+ return match cause.as_deref().map(launcher::why) {
412+ Some(Why::WindowsSandboxOff) => line(
413+ Tone::Off,
414+ title.into(),
415+ some("Windows Sandbox is off, or this edition of Windows has none: Home doesn't."),
416+ some("Linux jobs still run. For Windows jobs, on Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox in Windows features, then restart."),
417+ vec![WINDOWS_FEATURES],
418+ ),
419+ Some(Why::NoWsb) => line(
420+ Tone::Off,
421+ title.into(),
422+ some("This Windows has Windows Sandbox, but not its wsb command, which came with Windows 11 24H2."),
423+ some("Linux jobs still run. For Windows jobs, update Windows to 24H2 or later."),
424+ vec![],
425+ ),
426+ Some(Why::MacNotReleased) => line(Tone::Off, title.into(), some("Sandboxed macOS jobs aren't released yet."), some("Linux jobs still run."), vec![]),
427+ _ => line(if capability.available { Tone::Problem } else { Tone::Off }, title.into(), cause, capability.fix.clone(), vec![]),
428+ };
429+ }
430+ match capability.state {
431+ State::Running => match &capability.job {
432+ Some(job) => line(Tone::Working, format!("Running {}", job.said()), None, None, vec![]),
433+ None => line(Tone::Ok, "Waiting for jobs".into(), None, None, vec![]),
434+ },
435+ State::Starting => line(Tone::Working, "Starting".into(), None, None, vec![]),
436+ State::Updating => line(Tone::Working, "Updating".into(), None, None, vec![]),
437+ State::Stopped => line(Tone::Off, "Stopped".into(), None, None, vec![]),
438+ State::Absent => line(Tone::Off, "Not set up yet".into(), None, None, vec![]),
439+ State::Error | State::Unknown => line(Tone::Off, "Not running".into(), capability.cause.clone(), capability.fix.clone(), vec![]),
440+ }
441+}
442+
239443 /// What the site says of this computer while g1t is closed on it.
240444 pub fn closed(facts: &Facts) -> Diagnosis {
241445 Diagnosis {
245449 cause: some(format!("This runner runs while g1t is open on {}.", facts.machine)),
246450 fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)),
247451 actions: vec![],
452+ capabilities: vec![],
248453 }
249454 }
250455
258463
259464 #[test]
260465 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] {
466+ for code in [Code::SignedOut, Code::NoWorkspace, Code::NotAllowed, Code::RegisterFailed, Code::NoDocker,
467+ Code::NoLauncher, Code::NoWsl, Code::WslMirrored, Code::NoVirtualization, Code::NoVfkit, Code::NoSysbox, Code::ImageFailed, Code::SandboxUnavailable, Code::SandboxFailed,
468+ Code::SandboxTaken, Code::UpdateFailed, Code::Offline, Code::Crashed,
469+ ] {
262470 let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() });
263471 assert!(diagnosis.cause.is_some(), "{code:?} has no cause");
264472 assert!(diagnosis.fix.is_some(), "{code:?} has no fix");
281489 assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker");
282490 assert_eq!(closed(&facts()).title, "g1t is closed on build-box");
283491 }
492+
493+ fn status(json: &str) -> Status {
494+ launcher::parse(json).unwrap()
495+ }
496+
497+ const NO_WSL_PRO: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
498+ "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
499+ "capabilities":[
500+ {"id":"linux","available":false,"state":"error","isolation":"nested-container",
501+ "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
502+ "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."},
503+ {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.2.1","job":null}]}"#;
504+
505+ #[test]
506+ fn without_wsl_the_fix_is_the_command_to_copy_and_windows_jobs_still_show() {
507+ let status = status(NO_WSL_PRO);
508+ let (code, cause, _) = sandbox_trouble(&status).unwrap();
509+ assert_eq!(code, Code::NoWsl);
510+ let diagnosis = diagnose(code, &Facts { detail: Some(cause), sandbox: Some(status), ..facts() });
511+ assert_eq!(diagnosis.title, "Not running: WSL isn't installed");
512+ 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."));
513+ assert_eq!(diagnosis.actions[0], COPY_WSL_INSTALL);
514+ let [linux, windows] = &diagnosis.capabilities[..] else { panic!("{:?}", diagnosis.capabilities) };
515+ assert_eq!((linux.label.as_str(), linux.title.as_str(), linux.tone), ("Linux jobs", "Not available", Tone::Problem));
516+ assert_eq!(linux.fix, diagnosis.fix);
517+ assert_eq!(linux.actions[0].id, "copy_wsl_install");
518+ assert_eq!((windows.label.as_str(), windows.title.as_str(), windows.tone), ("Windows jobs", "Waiting for jobs", Tone::Ok));
519+ }
520+
521+ #[test]
522+ fn on_home_windows_jobs_are_unavailable_and_linux_jobs_still_run() {
523+ let status = status(
524+ r#"{"schema":1,"state":"running","backend":"wsl2","runner_version":"0.3.0","launcher_version":"0.3.0",
525+ "job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"},
526+ "capabilities":[
527+ {"id":"linux","available":true,"state":"running","runner_version":"0.3.0","job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"}},
528+ {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"On Windows 11 Pro…"}]}"#,
529+ );
530+ assert_eq!(sandbox_trouble(&status), None);
531+ let busy = diagnose(Code::Busy, &Facts { job: status.job().map(launcher::Job::said), sandbox: Some(status), ..facts() });
532+ assert_eq!(busy.title, "Running build (acme/web)");
533+ let [linux, windows] = &busy.capabilities[..] else { panic!() };
534+ assert_eq!((linux.title.as_str(), linux.tone), ("Running build (acme/web)", Tone::Working));
535+ assert_eq!((windows.title.as_str(), windows.tone), ("Not available", Tone::Off));
536+ assert_eq!(windows.cause.as_deref(), Some("Windows Sandbox is off, or this edition of Windows has none: Home doesn't."));
537+ assert!(windows.fix.as_deref().unwrap().starts_with("Linux jobs still run."));
538+ assert_eq!(windows.actions[0].id, "windows_features");
539+ }
540+
541+ #[test]
542+ fn each_launcher_trouble_has_its_words() {
543+ let trouble = |linux_available: bool, text: &str| {
544+ let json = serde_json::json!({
545+ "schema": 1, "state": "error", "error": text,
546+ "capabilities": [{"id": "linux", "available": linux_available, "state": "error", "cause": if linux_available { None } else { Some(text) }, "fix": "the launcher's fix"}]
547+ });
548+ sandbox_trouble(&status(&json.to_string())).unwrap()
549+ };
550+ 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);
551+ assert_eq!(trouble(false, "vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.").0, Code::NoVfkit);
552+ assert_eq!(trouble(false, "This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.").0, Code::NoSysbox);
553+ assert_eq!(trouble(false, "WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.").0, Code::WslMirrored);
554+ 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);
555+ // Unknown words: shown as the launcher wrote them, its fix with them.
556+ let (code, cause, fix) = trouble(false, "This user may not use /dev/kvm.");
557+ assert_eq!(code, Code::SandboxUnavailable);
558+ let unavailable = diagnose(code, &Facts { detail: Some(cause), fix, ..facts() });
559+ assert_eq!(unavailable.cause.as_deref(), Some("This user may not use /dev/kvm."));
560+ assert_eq!(unavailable.fix.as_deref(), Some("the launcher's fix"));
561+ let (code, cause, _) = trouble(true, "the sandbox did not start within 600 seconds");
562+ assert_eq!(code, Code::SandboxFailed);
563+ let failed = diagnose(code, &Facts { detail: Some(cause), wait_seconds: Some(60), ..facts() });
564+ assert_eq!(failed.cause.as_deref(), Some("the sandbox did not start within 600 seconds"));
565+ assert_eq!(failed.fix.as_deref(), Some("It starts again by itself in 60 seconds. Retry now, or look at its log."));
566+ let image = diagnose(Code::ImageFailed, &Facts { detail: Some("could not fetch https://g1t.sh/x: dns error".into()), ..facts() });
567+ assert_eq!(image.cause.as_deref(), Some("could not fetch https://g1t.sh/x: dns error"));
568+ assert_eq!(diagnose(Code::NoVirtualization, &Facts { platform: "macos".into(), ..facts() }).actions[0].id, "retry");
569+ assert_eq!(diagnose(Code::NoVfkit, &facts()).actions[0].id, "copy_vfkit_install");
570+ }
571+
572+ #[test]
573+ fn a_diagnosis_without_the_sandbox_lists_no_capabilities() {
574+ let json = serde_json::to_value(diagnose(Code::Idle, &Facts { backend: "in Docker", ..facts() })).unwrap();
575+ assert!(json.get("capabilities").is_none());
576+ let sandboxed = serde_json::to_value(diagnose(Code::Idle, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() })).unwrap();
577+ assert_eq!(sandboxed["capabilities"][1]["id"], "windows");
578+ }
284579 }
+527−32
1−//! The one place the app meets `g1t-runner sandbox`: the launcher that
2−//! makes a Linux VM of its own on this computer (Hyper-V or WSL on
3−//! Windows, Apple's Virtualization framework on macOS, Sysbox or KVM on
4−//! Linux) and runs the runner inside it, so jobs are containers inside the
5−//! runner's sandbox and none of it reaches this computer
6−//! (`nested-container` isolation).
1+//! The one place the app meets `g1t-runner sandbox`, the launcher that
2+//! makes a sandbox of the runner's own on this computer and runs the
3+//! runner inside it (crates/runner/src/selfhosted/launcher): Linux jobs in
4+//! a Linux VM (a WSL 2 distribution on Windows, vfkit on macOS, Sysbox or
5+//! QEMU with KVM on Linux), each a fresh container inside it
6+//! (`nested-container`), and, on Windows 11 Pro and up, Windows jobs each
7+//! in a Windows Sandbox of its own (`vm`).
78 //!
8−//! It is being built in crates/runner. Until it is released, `READY` is
9−//! false: the Sandboxed backend uses the runner's image under Sysbox where
10−//! this computer's Docker has it (container.rs), and says a sandbox isn't
11−//! available everywhere else. The commands below are the expected shape
12−//! (`sandbox up|down|update`, configured like `register-and-run`, from
13−//! `G1T_RUNNER_*` in the environment); when the launcher lands, this file
14−//! is the only one that changes.
9+//! It is the `g1t-runner` the app already keeps up to date (native.rs),
10+//! with its state in the app's runner folder (`--state-dir`). The app runs
11+//! four commands, each with `--json`, and reads the one object each prints
12+//! last (the launcher's documented status, schema 1):
13+//!
14+//! - `up --url … --name … --labels desktop [--token …]`, after the window
15+//! opens: makes the sandbox if there is none and starts it, waiting until
16+//! the runner asks for work. The token goes only to the first `up`; the
17+//! launcher keeps the registration from then on, across updates too. It
18+//! adds `-linux` and `-windows` to the name, and the OS labels, itself.
19+//! - `status`, every few seconds, for the tray, settings and the site.
20+//! - `down` as g1t quits or the person stops it; `down --drain` when they
21+//! choose another way of running jobs; `down --delete` when the
22+//! registration is no longer the one wanted.
23+//! - `update`, while nothing runs: the newest signed image, the same
24+//! registration.
25+//!
26+//! What a status's causes and errors mean is read here (`why`); what the
27+//! app says about them is diagnosis.rs's.
1528
29+use std::ffi::OsString;
1630 use std::path::Path;
1731 use std::process::{Command, Stdio};
32+use std::time::Duration;
33+
34+use serde::Deserialize;
1835
1936 use super::backend::quiet;
2037
21−/// Whether the released `g1t-runner` has its sandbox launcher.
22−pub const READY: bool = false;
38+/// The launcher's state folder, under the app's runner folder.
39+pub const FOLDER: &str = "sandbox";
40+/// The status shape the app reads (`schema`).
41+pub const SCHEMA: u32 = 1;
42+/// How often the app asks the sandbox how it is: a call takes about a second.
43+pub const STATUS_EVERY: Duration = Duration::from_secs(8);
44+/// What installs WSL on Windows, for the person to run as administrator.
45+pub const WSL_INSTALL: &str = "wsl --install --no-distribution";
46+/// What installs vfkit on a Mac.
47+pub const VFKIT_INSTALL: &str = "brew install vfkit";
48+
49+/// A sandbox's, or one capability's, state.
50+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
51+#[serde(rename_all = "snake_case")]
52+pub enum State {
53+ /// Never made, or deleted.
54+ #[default]
55+ Absent,
56+ Stopped,
57+ Starting,
58+ Running,
59+ Updating,
60+ Error,
61+ /// A state a newer launcher has that this app does not know.
62+ #[serde(other)]
63+ Unknown,
64+}
65+
66+/// What a runner is running now.
67+#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
68+#[serde(default)]
69+pub struct Job {
70+ /// `workflow` or `agent`.
71+ pub kind: String,
72+ pub id: String,
73+ pub name: String,
74+ pub repo: String,
75+}
76+
77+impl Job {
78+ /// How the tray says it: `build (acme/web)`.
79+ pub fn said(&self) -> String {
80+ if self.repo.is_empty() { self.name.clone() } else { format!("{} ({})", self.name, self.repo) }
81+ }
82+}
83+
84+/// One kind of job the computer can run sandboxed: `linux`, `windows` or `macos`.
85+#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
86+#[serde(default)]
87+pub struct Capability {
88+ pub id: String,
89+ pub available: bool,
90+ pub state: State,
91+ pub isolation: Option<String>,
92+ pub runner_version: Option<String>,
93+ pub job: Option<Job>,
94+ /// Why it is not available, in a sentence.
95+ pub cause: Option<String>,
96+ /// What to do about it, in a sentence.
97+ pub fix: Option<String>,
98+}
99+
100+/// `g1t-runner sandbox status --json`. The top-level fields are the Linux
101+/// VM's. Fields the app does not use (`resources`, `state_dir`) are left
102+/// out, and any a newer launcher adds are ignored.
103+#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
104+#[serde(default)]
105+pub struct Status {
106+ pub schema: u32,
107+ pub state: State,
108+ /// `wsl2`, `vfkit`, `qemu` or `sysbox`.
109+ pub backend: Option<String>,
110+ pub runner_version: Option<String>,
111+ pub launcher_version: Option<String>,
112+ pub image_version: Option<String>,
113+ pub isolation: Option<String>,
114+ pub job: Option<Job>,
115+ /// A sentence for a person, whenever something is wrong.
116+ pub error: Option<String>,
117+ pub capabilities: Vec<Capability>,
118+}
119+
120+impl Status {
121+ pub fn capability(&self, id: &str) -> Option<&Capability> {
122+ self.capabilities.iter().find(|c| c.id == id)
123+ }
124+
125+ /// The Linux capability, as the top-level fields say it when a launcher lists none.
126+ pub fn linux(&self) -> Capability {
127+ self.capability("linux").cloned().unwrap_or_else(|| Capability {
128+ id: "linux".into(),
129+ available: self.state != State::Error || self.error.is_none(),
130+ state: self.state,
131+ runner_version: self.runner_version.clone(),
132+ job: self.job.clone(),
133+ ..Capability::default()
134+ })
135+ }
136+
137+ /// Whether any of its runners is running.
138+ pub fn running(&self) -> bool {
139+ self.state == State::Running || self.capabilities.iter().any(|c| c.state == State::Running)
140+ }
141+
142+ /// Whether anything here can be sandboxed at all.
143+ pub fn anything_available(&self) -> bool {
144+ if self.capabilities.is_empty() { self.linux().available } else { self.capabilities.iter().any(|c| c.available) }
145+ }
146+
147+ /// A capability that can be had here but was never set up: registering
148+ /// it needs a token.
149+ pub fn unregistered(&self) -> bool {
150+ let wanting = |c: &Capability| c.available && c.state == State::Absent;
151+ if self.capabilities.is_empty() { wanting(&self.linux()) } else { self.capabilities.iter().any(wanting) }
152+ }
153+
154+ /// What a runner is running, the Linux one's first.
155+ pub fn job(&self) -> Option<&Job> {
156+ self.job.as_ref().or_else(|| self.capabilities.iter().find_map(|c| c.job.as_ref()))
157+ }
158+
159+ /// The runner's version, as the tray and the site say it.
160+ pub fn version(&self) -> Option<String> {
161+ self.runner_version
162+ .clone()
163+ .or_else(|| self.capabilities.iter().find_map(|c| c.runner_version.clone()))
164+ .or_else(|| self.launcher_version.clone())
165+ }
166+
167+ /// What is wrong with Linux jobs, in the launcher's words: its error,
168+ /// else why the capability is unavailable.
169+ pub fn trouble(&self) -> Option<(String, Option<String>)> {
170+ let linux = self.linux();
171+ if !linux.available {
172+ let cause = linux.cause.or_else(|| self.error.clone())?;
173+ return Some((cause, linux.fix));
174+ }
175+ if linux.state == State::Error || self.state == State::Error {
176+ return Some((self.error.clone().unwrap_or_else(|| "The sandbox stopped, and didn't say why.".into()), None));
177+ }
178+ None
179+ }
180+}
181+
182+/// Reads what a launcher command printed: log lines, then the status as
183+/// one JSON object, which starts on a line of its own. A launcher whose
184+/// schema is not this app's is not read, nor is a `g1t-runner` from before
185+/// the launcher, which prints no status at all.
186+pub fn parse(output: &str) -> Result<Status, String> {
187+ let start = output
188+ .match_indices('\n')
189+ .map(|(at, _)| at + 1)
190+ .chain(std::iter::once(0))
191+ .filter(|at| output[*at..].starts_with('{'))
192+ .max()
193+ .ok_or_else(|| "this g1t-runner printed no sandbox status: it is older than its sandbox launcher".to_owned())?;
194+ let status: Status = serde_json::from_str(output[start..].trim()).map_err(|e| format!("the sandbox's status doesn't read: {e}"))?;
195+ if status.schema != SCHEMA {
196+ return Err(format!("the sandbox's status is in schema {}, and this g1t reads schema {SCHEMA}: update g1t", status.schema));
197+ }
198+ Ok(status)
199+}
200+
201+/// Whether a line of a launcher's output starts its status, rather than being a log line.
202+pub fn starts_status(line: &str) -> bool {
203+ line.starts_with('{')
204+}
205+
206+/// What `up` is told the first time, and again on every start (the
207+/// launcher keeps what it was told, and `--token` only matters once).
208+#[derive(Clone, PartialEq, Eq)]
209+pub struct Up<'a> {
210+ pub url: &'a str,
211+ pub name: &'a str,
212+ pub labels: &'a str,
213+ /// The registration token: only for a sandbox that has no registration yet.
214+ pub token: Option<&'a str>,
215+}
216+
217+// Never print a token, in a log or a panic.
218+impl std::fmt::Debug for Up<'_> {
219+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220+ f.debug_struct("Up").field("url", &self.url).field("name", &self.name).field("labels", &self.labels).field("token", &self.token.map(|_| "…")).finish()
221+ }
222+}
223+
224+fn args(action: &str, dir: &Path) -> Vec<OsString> {
225+ let mut args: Vec<OsString> = vec!["sandbox".into(), action.into(), "--json".into(), "--state-dir".into()];
226+ args.push(dir.as_os_str().to_owned());
227+ args
228+}
229+
230+/// `sandbox up`'s arguments.
231+pub fn up_args(dir: &Path, up: &Up) -> Vec<OsString> {
232+ let mut args = args("up", dir);
233+ for (name, value) in [("--url", up.url), ("--name", up.name), ("--labels", up.labels)] {
234+ args.push(name.into());
235+ args.push(value.into());
236+ }
237+ if let Some(token) = up.token {
238+ args.push("--token".into());
239+ args.push(token.into());
240+ }
241+ args
242+}
243+
244+/// How `down` stops the sandbox.
245+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
246+pub enum Down {
247+ /// Now: a running job is cut short (as g1t quits, or Stop).
248+ Now,
249+ /// After the running job (another way of running jobs was chosen).
250+ Drain,
251+ /// Now, and the VM and the registration with it (another workspace or server).
252+ Delete,
253+}
23254
24−/// The launcher's own folder under the app's runner folder: its VM and the
25−/// runner's registration inside it are kept there.
26−pub const FOLDER: &str = "sandbox";
255+pub fn down_args(dir: &Path, how: Down) -> Vec<OsString> {
256+ let mut args = args("down", dir);
257+ match how {
258+ Down::Now => {}
259+ Down::Drain => args.push("--drain".into()),
260+ Down::Delete => args.push("--delete".into()),
261+ }
262+ args
263+}
264+
265+pub fn status_args(dir: &Path) -> Vec<OsString> {
266+ args("status", dir)
267+}
27268
28−fn sandbox(binary: &Path, action: &str, dir: &Path) -> Command {
269+pub fn update_args(dir: &Path) -> Vec<OsString> {
270+ args("update", dir)
271+}
272+
273+/// A launcher command whose output the app reads a line at a time
274+/// (`up` and `update`, which take minutes the first time).
275+pub fn command(binary: &Path, args: Vec<OsString>) -> Command {
29276 let mut command = quiet(binary);
30− command.args(["sandbox", action, "--dir"]).arg(dir);
277+ // What the app's own environment may have set for another runner is not this one's.
278+ for (name, _) in std::env::vars_os() {
279+ if name.to_string_lossy().starts_with("G1T_RUNNER_") {
280+ command.env_remove(&name);
281+ }
282+ }
283+ command.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
31284 command
32285 }
33286
34−/// Makes the sandbox if it is not there, starts it, and runs the runner
35−/// inside it until `down`: a child whose output is the runner's log.
36−pub fn up(binary: &Path, dir: &Path) -> Command {
37− let mut command = sandbox(binary, "up", dir);
38− command.stdout(Stdio::piped()).stderr(Stdio::piped());
39− command
287+/// Runs a short launcher command to its end and reads its status, which
288+/// it prints whether it worked or not.
289+fn run(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
290+ let out = command(binary, args).output().map_err(|e| format!("g1t-runner could not start: {e}"))?;
291+ let printed = String::from_utf8_lossy(&out.stdout);
292+ match parse(&printed) {
293+ Ok(status) => Ok(status),
294+ Err(_) if !out.status.success() && !out.stderr.is_empty() => Err(String::from_utf8_lossy(&out.stderr).trim().trim_start_matches("g1t-runner: ").to_owned()),
295+ Err(why) => Err(why),
296+ }
297+}
298+
299+/// How the sandbox is now.
300+pub fn status(binary: &Path, dir: &Path) -> Result<Status, String> {
301+ run(binary, status_args(dir))
302+}
303+
304+/// Stops the sandbox, waiting for it.
305+pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<(), String> {
306+ if !binary.exists() {
307+ return Err("g1t-runner isn't installed".into());
308+ }
309+ let status = run(binary, down_args(dir, how))?;
310+ match status.error {
311+ Some(error) => Err(error),
312+ None => Ok(()),
313+ }
314+}
315+
316+/// What a launcher's sentence is about: the words the app shows for it
317+/// are diagnosis.rs's. Read from the causes and errors the launcher
318+/// writes (crates/runner/src/selfhosted/launcher and native.rs).
319+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
320+pub enum Why {
321+ NoWsl,
322+ WslMirrored,
323+ NoVirtualization,
324+ NoVfkit,
325+ /// Linux, with neither Sysbox in Docker nor KVM.
326+ NoSysboxOrKvm,
327+ /// The sandbox's image couldn't be downloaded, or isn't the release's.
328+ Image,
329+ /// g1t refused the registration token.
330+ TokenRefused,
331+ /// g1t no longer knows the runner.
332+ Forgotten,
333+ /// The sandbox has no registration, and no token was given.
334+ Unregistered,
335+ /// Windows Sandbox is off, or this edition of Windows has none.
336+ WindowsSandboxOff,
337+ /// Windows Sandbox without `wsb`, from before Windows 11 24H2.
338+ NoWsb,
339+ /// Sandboxed macOS jobs are not released.
340+ MacNotReleased,
341+ /// Anything else: shown in the launcher's own words.
342+ Other,
40343 }
41344
42−/// Stops the runner and the sandbox around it, waiting for neither job nor VM to linger.
43−pub fn down(binary: &Path, dir: &Path) {
44− let _ = sandbox(binary, "down", dir).stdout(Stdio::null()).stderr(Stdio::null()).status();
345+pub fn why(text: &str) -> Why {
346+ let lower = text.to_ascii_lowercase();
347+ let has = |words: &str| lower.contains(words);
348+ if has("wsl is not installed") {
349+ Why::NoWsl
350+ } else if has("mirrored") {
351+ Why::WslMirrored
352+ } else if has("virtualization is not enabled")
353+ || has("enable virtualization")
354+ || has("virtual machine platform")
355+ || has("hcs_e_hyperv_not_installed")
356+ || has("vt-x")
357+ || has("hypervisor is not present")
358+ {
359+ Why::NoVirtualization
360+ } else if has("vfkit") && has("not installed") {
361+ Why::NoVfkit
362+ } else if has("neither sysbox nor kvm") || has("docker has no sysbox") {
363+ Why::NoSysboxOrKvm
364+ } else if has("token") && (has("not valid") || has("expired") || has("invalid")) {
365+ Why::TokenRefused
366+ } else if has("no longer knows this runner") || has("was removed from g1t") {
367+ Why::Forgotten
368+ } else if has("is not registered") {
369+ Why::Unregistered
370+ } else if has("sandbox image") || has("sandbox's images") || has("no sandbox images") || has("sha-256") || has("signature") || has("release key") || has("could not fetch") || has("broke off") {
371+ Why::Image
372+ } else if has("windows sandbox is not turned on") {
373+ Why::WindowsSandboxOff
374+ } else if has("24h2") {
375+ Why::NoWsb
376+ } else if has("macos jobs are not released") {
377+ Why::MacNotReleased
378+ } else {
379+ Why::Other
380+ }
45381 }
46382
47−/// Brings the sandbox's image to the release of `binary`; true when it changed.
48−pub fn update(binary: &Path, dir: &Path) -> bool {
49− sandbox(binary, "update", dir).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|status| status.success())
383+#[cfg(test)]
384+mod tests {
385+ use super::*;
386+ use std::path::PathBuf;
387+
388+ /// What `status --json` printed on a Windows 11 Pro machine with WSL
389+ /// and Windows Sandbox, before `up` (g1t-runner 0.2.1).
390+ const ABSENT: &str = r#"{
391+ "schema": 1,
392+ "state": "absent",
393+ "backend": "wsl2",
394+ "runner_version": null,
395+ "launcher_version": "0.2.1",
396+ "image_version": null,
397+ "isolation": "nested-container",
398+ "job": null,
399+ "error": null,
400+ "resources": {
401+ "cpus": 0,
402+ "memory_mib": 0,
403+ "disk_gib": 0
404+ },
405+ "capabilities": [
406+ {
407+ "id": "linux",
408+ "available": true,
409+ "state": "absent",
410+ "isolation": "nested-container",
411+ "runner_version": null,
412+ "job": null,
413+ "cause": null,
414+ "fix": null
415+ },
416+ {
417+ "id": "windows",
418+ "available": true,
419+ "state": "absent",
420+ "isolation": "vm",
421+ "runner_version": null,
422+ "job": null,
423+ "cause": null,
424+ "fix": null
425+ }
426+ ],
427+ "state_dir": "C:\\Users\\syntaqx\\.g1t-runner\\sandbox"
428+}"#;
429+
430+ /// The launcher's documented example: running a job, Windows Sandbox off.
431+ const RUNNING: &str = r#"{
432+ "schema": 1, "state": "running", "backend": "wsl2",
433+ "runner_version": "0.3.0", "launcher_version": "0.3.0", "image_version": "0.3.0",
434+ "isolation": "nested-container",
435+ "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" },
436+ "error": null,
437+ "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 },
438+ "capabilities": [
439+ { "id": "linux", "available": true, "state": "running", "isolation": "nested-container",
440+ "runner_version": "0.3.0", "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" }, "cause": null, "fix": null },
441+ { "id": "windows", "available": false, "state": "absent", "isolation": "vm",
442+ "runner_version": null, "job": null,
443+ "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
444+ "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." }
445+ ],
446+ "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox",
447+ "something_new": { "a": 1 }
448+}"#;
449+
450+ #[test]
451+ fn a_status_is_read_from_after_the_log() {
452+ let printed = format!("2026-10-12T08:00:00Z Downloading the sandbox image wsl-x64.\n2026-10-12T08:03:00Z {{not json}}\n{RUNNING}\n");
453+ let status = parse(&printed).unwrap();
454+ assert_eq!(status.state, State::Running);
455+ assert_eq!(status.backend.as_deref(), Some("wsl2"));
456+ assert_eq!(status.job().map(Job::said).as_deref(), Some("build (acme/web)"));
457+ assert_eq!(status.version().as_deref(), Some("0.3.0"));
458+ assert!(status.running());
459+ assert!(!status.unregistered());
460+ let windows = status.capability("windows").unwrap();
461+ assert!(!windows.available);
462+ assert_eq!(why(windows.cause.as_deref().unwrap()), Why::WindowsSandboxOff);
463+ assert_eq!(status.trouble(), None);
464+ }
465+
466+ #[test]
467+ fn a_sandbox_never_made_needs_registering() {
468+ let status = parse(ABSENT).unwrap();
469+ assert_eq!(status.state, State::Absent);
470+ assert!(status.anything_available());
471+ assert!(status.unregistered());
472+ assert!(!status.running());
473+ assert_eq!(status.version().as_deref(), Some("0.2.1"));
474+ }
475+
476+ #[test]
477+ fn what_is_not_a_status_is_said_so() {
478+ assert!(parse("g1t-runner: unknown command sandbox\n").unwrap_err().contains("older than its sandbox launcher"));
479+ assert!(parse(&ABSENT.replace("\"schema\": 1", "\"schema\": 2")).unwrap_err().contains("schema 2"));
480+ let unknown = parse(&ABSENT.replace("\"state\": \"absent\",\n \"backend\"", "\"state\": \"hibernating\",\n \"backend\"")).unwrap();
481+ assert_eq!(unknown.state, State::Unknown);
482+ assert!(starts_status("{") && !starts_status(" \"schema\": 1,"));
483+ }
484+
485+ #[test]
486+ fn without_wsl_linux_says_why_and_nothing_else_can_run_on_home() {
487+ let status = parse(
488+ r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
489+ "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
490+ "capabilities":[
491+ {"id":"linux","available":false,"state":"error",
492+ "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
493+ "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows."},
494+ {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"…"}]}"#,
495+ )
496+ .unwrap();
497+ assert!(!status.anything_available());
498+ let (cause, fix) = status.trouble().unwrap();
499+ assert_eq!(why(&cause), Why::NoWsl);
500+ assert!(fix.unwrap().contains(WSL_INSTALL));
501+ }
502+
503+ #[test]
504+ fn the_launchers_sentences_are_told_apart() {
505+ for (text, expected) in [
506+ ("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.", Why::WslMirrored),
507+ ("WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.", Why::NoVirtualization),
508+ ("vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.", Why::NoVfkit),
509+ ("This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.", Why::NoSysboxOrKvm),
510+ ("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it", Why::Image),
511+ ("could not fetch https://g1t.sh/downloads/runner/0.3.0/wsl-x64.tar.gz: dns error", Why::Image),
512+ ("release 0.3.0 has no sandbox images", Why::Image),
513+ ("The registration token is not valid, or has expired.", Why::TokenRefused),
514+ ("g1t no longer knows this runner (401 unauthorized). Register it again.", Why::Forgotten),
515+ ("the sandbox's runner is not registered, and no --url and --token were given", Why::Unregistered),
516+ ("This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.", Why::NoWsb),
517+ ("Sandboxed macOS jobs are not released yet.", Why::MacNotReleased),
518+ ("This user may not use /dev/kvm.", Why::Other),
519+ ] {
520+ assert_eq!(why(text), expected, "{text}");
521+ }
522+ }
523+
524+ #[test]
525+ fn the_commands_are_the_launchers() {
526+ let dir = PathBuf::from("data").join("runner").join(FOLDER);
527+ let text = |args: Vec<OsString>| args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
528+ let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret") };
529+ let up = text(up_args(&dir, &first));
530+ assert_eq!(&up[..4], ["sandbox", "up", "--json", "--state-dir"]);
531+ assert_eq!(up[4], dir.to_string_lossy());
532+ assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--token", "g1trt_secret"]);
533+ // Its name and labels as the app has them: the launcher adds `-linux` and the OS labels.
534+ assert!(!up.iter().any(|a| a.ends_with("-linux") || a.contains("windows")));
535+ let again = text(up_args(&dir, &Up { token: None, ..first.clone() }));
536+ assert!(!again.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
537+ assert!(!format!("{first:?}").contains("g1trt_secret"));
538+ assert_eq!(text(down_args(&dir, Down::Now))[..3], ["sandbox", "down", "--json"]);
539+ assert_eq!(text(down_args(&dir, Down::Now)).len(), 5);
540+ assert_eq!(text(down_args(&dir, Down::Drain)).last().unwrap(), "--drain");
541+ assert_eq!(text(down_args(&dir, Down::Delete)).last().unwrap(), "--delete");
542+ assert_eq!(text(status_args(&dir))[..3], ["sandbox", "status", "--json"]);
543+ assert_eq!(text(update_args(&dir))[..3], ["sandbox", "update", "--json"]);
544+ }
50545 }
+670−154
1111 //! - **How.** The backend settings choose (backend.rs): Sandboxed, the
1212 //! default, where nothing a job does reaches this computer; Docker; or
1313 //! directly on this computer. The two that are not sandboxes are only
14−//! ever chosen by the person, and nothing falls back to them.
14+//! ever chosen by the person, and nothing falls back to them. Sandboxed
15+//! is `g1t-runner sandbox` (launcher.rs) on every OS: `up` a few seconds
16+//! after the window, `status` every few seconds while it runs, `down`
17+//! as g1t quits, and `down --drain` when another backend is chosen.
1518 //! - **Registering.** The first time, and when the workspace or backend
1619 //! changes, the app needs a registration token for the workspace. Only
1720 //! the site can make one, for an owner who is signed in: the app asks
1821 //! the site's page (`g1t-runner-wanted`), the page asks the server
1922 //! (`POST /-/desktop/runner-token`) and hands the token to the app
20−//! (`runner_register` in bridge.rs), which gives it to `g1t-runner` in
21−//! its environment, once. It is never shown or written to the log. The
22−//! registration is kept (registration.json, and the runner's own folder
23−//! or volume) and used on every later start.
23+//! (`runner_register` in bridge.rs), which gives it to `g1t-runner`
24+//! once: in its environment, or to the sandbox's first `up`. It is never
25+//! shown or written to the log. The registration is kept
26+//! (registration.json and the runner's own folder; the sandbox keeps its
27+//! own, and sandbox-registration.json says whose it is) and used on
28+//! every later start.
2429 //! - **Up to date.** Soon after it starts and every six hours, while the
25−//! runner is idle, the app looks for a newer release, or a newer image,
26−//! and puts it in place: never in the middle of a job.
30+//! runner is idle, the app looks for a newer release and puts it in
31+//! place, and has the sandbox take the newest image (`sandbox update`):
32+//! never in the middle of a job.
2733 //! - **Seen.** What it is doing, and when it is not running why and how to
2834 //! fix it (diagnosis.rs), is in the tray, in settings, and on the site's
2935 //! Runners page (`g1t-runner-status`), read from what its log says
30−//! (log.rs). The whole log is kept in `runner/runner.log`.
36+//! (log.rs), or for the sandbox from its status. The whole log is kept
37+//! in `runner/runner.log`.
3138 //!
3239 //! All of it runs on a thread of its own, which owns the runner's process
3340 //! and is told what happened through one channel.
3441
3542 mod backend;
36−mod container;
3743 pub mod diagnosis;
3844 mod launcher;
3945 mod log;
4349 use std::fs::{File, OpenOptions};
4450 use std::io::{BufRead, BufReader, Read, Write};
4551 use std::path::PathBuf;
46−use std::process::Child;
52+use std::process::{Child, Stdio};
4753 use std::sync::Mutex;
4854 use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
4955 use std::time::{Duration, Instant};
5561 pub use backend::Backend;
5662 use backend::{Engine, Missing};
5763 use diagnosis::{Code, Diagnosis, Facts};
64+use launcher::{Down, State, Status};
5865
5966 use crate::{config, frame, settings, tray};
6067
101108 wait_seconds: Option<u64>,
102109 job: Option<String>,
103110 version: Option<String>,
111+ /// What the sandbox says to do, for a cause in its own words.
112+ fix: Option<String>,
113+ /// How the sandbox is, while jobs run sandboxed.
114+ sandbox: Option<Status>,
104115 }
105116
106117 /// What the site last said about the person.
157168 Retry,
158169 Token { workspace: String, token: String },
159170 Refused { workspace: String, allowed: bool, why: String },
160− Line(u64, String),
171+ /// A line of the runner's output, or the launcher's; true from its standard output.
172+ Line(u64, String, bool),
161173 Exited(u64),
174+ /// What `sandbox status` said.
175+ Sandbox(u64, Result<Box<Status>, String>),
162176 Quit(Sender<()>),
163177 }
164178
170184 backend: Backend,
171185 }
172186
187+/// Whose the sandbox's registration is. The sandbox keeps its own, and
188+/// keeps it while another backend runs, so choosing Sandboxed again needs
189+/// no new one; this says which workspace it is for.
190+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
191+struct SandboxRegistration {
192+ server: String,
193+ workspace: String,
194+ /// False from the `up` that was given a token until it registered.
195+ registered: bool,
196+}
197+
198+impl SandboxRegistration {
199+ fn path(app: &AppHandle) -> PathBuf {
200+ folder(app).join("sandbox-registration.json")
201+ }
202+ fn load(app: &AppHandle) -> Option<SandboxRegistration> {
203+ std::fs::read(Self::path(app)).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok())
204+ }
205+ fn save(&self, app: &AppHandle) {
206+ let _ = std::fs::create_dir_all(folder(app));
207+ let _ = std::fs::write(Self::path(app), serde_json::to_vec_pretty(self).unwrap_or_default());
208+ }
209+ fn clear(app: &AppHandle) {
210+ let _ = std::fs::remove_file(Self::path(app));
211+ }
212+}
213+
173214 fn folder(app: &AppHandle) -> PathBuf {
174215 app.path().app_data_dir().unwrap_or_else(|_| std::env::temp_dir().join("sh.g1t.desktop")).join("runner")
175216 }
217258
218259 fn facts(app: &AppHandle, now: &Now) -> Facts {
219260 let server = config::server(app);
261+ let backend = config::settings(app).runner_backend;
220262 Facts {
221263 workspace: workspace(app),
222− backend: config::settings(app).runner_backend.label(),
264+ backend: backend.label(),
223265 machine: backend::machine_name(),
224266 job: now.job.clone(),
225267 detail: now.detail.clone(),
227269 wait_seconds: now.wait_seconds,
228270 server: server.host_str().unwrap_or("g1t").to_owned(),
229271 platform: std::env::consts::OS.into(),
272+ fix: now.fix.clone(),
273+ sandbox: now.sandbox.clone().filter(|_| backend == Backend::Sandboxed),
230274 }
231275 }
232276
239283 View {
240284 diagnosis: diagnosis::diagnose(now.code, &facts),
241285 closed: diagnosis::closed(&facts),
242− running: matches!(now.code, Code::Idle | Code::Busy | Code::Offline),
286+ running: matches!(now.code, Code::Idle | Code::Busy | Code::Offline) || facts.sandbox.as_ref().is_some_and(Status::running),
243287 version: now.version,
244288 backend: settings.runner_backend,
245289 link: facts.workspace.as_ref().and_then(|w| server.join(&format!("{w}/-/runners")).ok()).map(|u| u.to_string()),
366410 app.opener().open_path(path.to_string_lossy(), None::<&str>).map_err(|e| e.to_string())?;
367411 }
368412 "open_docker" => open_docker(app)?,
413+ "copy_wsl_install" => copy(launcher::WSL_INSTALL)?,
414+ "copy_vfkit_install" => copy(launcher::VFKIT_INSTALL)?,
415+ "windows_features" => windows_features()?,
369416 "get_docker" => browse("https://docs.docker.com/get-started/get-docker/")?,
370417 "install_sysbox" => browse("https://github.com/nestybox/sysbox#installation")?,
371− "learn_more" => browse("https://docs.g1t.sh/guides/desktop/#the-runner")?,
418+ "learn_more" => browse("https://docs.g1t.sh/guides/desktop/#how-jobs-run")?,
372419 "report" => browse(config::server(app).join("support").map_err(|e| e.to_string())?.as_str())?,
373420 other => return Err(format!("the runner has no action called {other}")),
374421 }
392439 Err("Start Docker with your system's service manager, such as `sudo systemctl start docker`.".into())
393440 }
394441
442+/// Puts a command on the clipboard, for the person to paste where it runs.
443+fn copy(text: &str) -> Result<(), String> {
444+ let tools: &[(&str, &[&str])] = if cfg!(windows) {
445+ &[("clip", &[])]
446+ } else if cfg!(target_os = "macos") {
447+ &[("pbcopy", &[])]
448+ } else {
449+ &[("wl-copy", &[]), ("xclip", &["-selection", "clipboard"]), ("xsel", &["--clipboard", "--input"])]
450+ };
451+ for (program, args) in tools {
452+ let Ok(mut child) = backend::quiet(program).args(*args).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else { continue };
453+ if let Some(mut input) = child.stdin.take() {
454+ let _ = input.write_all(text.as_bytes());
455+ }
456+ if child.wait().is_ok_and(|status| status.success()) {
457+ return Ok(());
458+ }
459+ }
460+ Err(format!("It couldn't be copied. The command is: {text}"))
461+}
462+
463+/// Windows' "Turn Windows features on or off", where Windows Sandbox and
464+/// Virtual Machine Platform are.
465+fn windows_features() -> Result<(), String> {
466+ if !cfg!(windows) {
467+ return Err("Windows features are only on Windows.".into());
468+ }
469+ backend::quiet("optionalfeatures.exe").spawn().map(|_| ()).map_err(|e| format!("Windows features couldn't be opened: {e}"))
470+}
471+
395472 /// The workspace a token is wanted for now, if one is: the site asks as it loads.
396473 pub fn wanted(app: &AppHandle) -> Option<String> {
397474 app.state::<Shared>().wanted.lock().unwrap().clone()
415492
416493 /// How the runner is started this time.
417494 enum Process {
418− /// `g1t-runner` or its sandbox launcher, a child of the app.
495+ /// `g1t-runner`, a child of the app, whose output is the runner's log.
419496 Child(Child),
420− /// The runner's container, and `docker logs --follow` on it.
421− Container(Child),
497+ /// The sandbox, which runs on its own once `up` is done; the launcher
498+ /// command the app is waiting for, while there is one.
499+ Sandbox(Option<(Step, Child)>),
500+}
501+
502+/// A launcher command that takes a while.
503+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
504+enum Step {
505+ Up,
506+ Update,
422507 }
423508
509+impl Step {
510+ fn name(self) -> &'static str {
511+ match self {
512+ Step::Up => "up",
513+ Step::Update => "update",
514+ }
515+ }
516+}
517+
424518 struct Run {
425519 generation: u64,
426520 engine: Engine,
437531 forgotten: bool,
438532 token_refused: bool,
439533 error: Option<String>,
534+ /// The status a launcher command is printing, line by line.
535+ json: Option<Vec<String>>,
536+ /// Asking the sandbox how it is, and when it was last asked.
537+ polling: bool,
538+ polled_at: Option<Instant>,
539+ /// A poll asked before a launcher command ended, whose answer is old.
540+ stale: bool,
440541 }
441542
543+impl Run {
544+ fn new(generation: u64, engine: Engine, backend: Backend, workspace: String, process: Process, with_token: bool) -> Run {
545+ Run {
546+ generation,
547+ engine,
548+ backend,
549+ workspace,
550+ process,
551+ listening: false,
552+ busy: false,
553+ offline_at: None,
554+ with_token,
555+ forgotten: false,
556+ token_refused: false,
557+ error: None,
558+ json: None,
559+ polling: false,
560+ polled_at: None,
561+ stale: false,
562+ }
563+ }
564+
565+ fn step(&self) -> Option<Step> {
566+ match &self.process {
567+ Process::Sandbox(Some((step, _))) => Some(*step),
568+ _ => None,
569+ }
570+ }
571+}
572+
442573 struct Keeper {
443574 app: AppHandle,
444575 tx: Sender<Ask>,
449580 failures: u32,
450581 next_update: Instant,
451582 log: Option<File>,
583+ /// The runner was updated: the sandbox takes its image before it starts again.
584+ image_pending: bool,
585+ /// Why the last update failed, said until the next one works.
586+ update_failed: Option<String>,
452587 }
453588
454589 /// What a set of the state carries besides its code.
457592 detail: Option<String>,
458593 exit_code: Option<i32>,
459594 wait_seconds: Option<u64>,
595+ fix: Option<String>,
460596 }
461597
462598 fn detail(text: impl Into<String>) -> Extra {
466602 impl Keeper {
467603 fn new(app: AppHandle, tx: Sender<Ask>) -> Keeper {
468604 let now = Instant::now();
469− Keeper { app, tx, run: None, generation: 0, token: None, not_before: now + AFTER_START, failures: 0, next_update: now + FIRST_UPDATE, log: None }
605+ Keeper { app, tx, run: None, generation: 0, token: None, not_before: now + AFTER_START, failures: 0, next_update: now + FIRST_UPDATE, log: None, image_pending: false, update_failed: None }
470606 }
471607
472608 fn keep(mut self, rx: Receiver<Ask>) {
495631 let shared = self.app.state::<Shared>();
496632 let mut now = shared.now.lock().unwrap();
497633 let job = if code == Code::Busy { now.job.clone() } else { None };
498− let next = Now { code, detail: extra.detail, exit_code: extra.exit_code, wait_seconds: extra.wait_seconds, job, version: now.version.clone() };
634+ let next = Now {
635+ code,
636+ detail: extra.detail,
637+ exit_code: extra.exit_code,
638+ wait_seconds: extra.wait_seconds,
639+ job,
640+ version: now.version.clone(),
641+ fix: extra.fix,
642+ sandbox: now.sandbox.clone(),
643+ };
499644 let changed = *now != next;
500645 *now = next;
501646 changed
551696 self.failures = 0;
552697 match self.code() {
553698 // The runner is fine: only the update is tried again.
554− Code::UpdateFailed => self.next_update = Instant::now(),
699+ Code::UpdateFailed => {
700+ self.next_update = Instant::now();
701+ self.update_failed = None;
702+ }
555703 Code::Offline => self.halt(),
556704 _ if self.run.as_ref().is_some_and(|run| !run.listening) => self.halt(),
557705 _ => {}
581729 self.set(if allowed { Code::RegisterFailed } else { Code::NotAllowed }, detail(why));
582730 }
583731 }
584− Ask::Line(generation, line) => self.line(generation, &line),
732+ Ask::Line(generation, line, out) => self.line(generation, &line, out),
585733 Ask::Exited(generation) => self.exited(generation),
734+ Ask::Sandbox(generation, status) => self.polled(generation, status),
586735 }
587736 }
588737
589− fn line(&mut self, generation: u64, line: &str) {
738+ fn line(&mut self, generation: u64, line: &str, out: bool) {
590739 let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
740+ if run.engine == Engine::Launcher {
741+ // The launcher's log, then its status, which is read when it ends.
742+ if out && (run.json.is_some() || launcher::starts_status(line)) {
743+ run.json.get_or_insert_with(Vec::new).push(line.to_owned());
744+ } else {
745+ self.write_log(line);
746+ }
747+ return;
748+ }
591749 let mut became = None;
592750 let mut job = None;
593751 let mut version = None;
623781 workspace: run.workspace.clone(),
624782 backend: run.backend,
625783 });
626− // A container started with a token keeps it in its configuration:
627− // it is replaced at once by one without, which needs no token.
628− let replace = registered.is_some() && run.with_token && matches!(run.process, Process::Container(_));
629784 self.write_log(line);
630785 if let Some(registration) = registered {
631786 registration.save(&self.app);
634789 self.note(job, version);
635790 if let Some(code) = became {
636791 self.set(code, Extra::default());
637− }
638− if replace {
639− self.halt();
640− self.not_before = Instant::now();
641792 }
642793 }
643794
645796 if self.run.as_ref().is_none_or(|run| run.generation != generation) {
646797 return;
647798 }
799+ if self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher) {
800+ return self.launched(generation);
801+ }
648802 let mut run = self.run.take().expect("checked above");
649803 let exit_code = match &mut run.process {
650804 Process::Child(child) => child.wait().ok().and_then(|status| status.code()),
651− Process::Container(_) => container::exit_code(),
805+ Process::Sandbox(_) => None,
652806 };
653− self.stop(&mut run);
807+ self.stop(&mut run, Down::Now);
654808 if run.forgotten || run.token_refused {
655809 // The registration is no more, or was never made: the next start asks for a token.
656810 self.forget();
666820 } else if !run.listening && Registration::load(&self.app).is_none() {
667821 self.set(Code::RegisterFailed, Extra { detail: run.error, ..Extra::default() });
668822 } else {
669− self.set(Code::Crashed, Extra { detail: run.error, exit_code, wait_seconds: Some(wait.as_secs()) });
823+ self.set(Code::Crashed, Extra { detail: run.error, exit_code, wait_seconds: Some(wait.as_secs()), ..Extra::default() });
670824 }
671825 }
672826
673− /// Stops a run's process, the way it was started.
674− fn stop(&self, run: &mut Run) {
827+ /// Stops a run's process, the way it was started: the sandbox `how` says.
828+ fn stop(&mut self, run: &mut Run, how: Down) {
675829 match (&mut run.process, run.engine) {
676− (Process::Container(logs), _) => container::stop(Some(logs)),
677− (Process::Child(child), Engine::Launcher) => {
678− launcher::down(&native::binary(&folder(&self.app)), &native_dir(&self.app, run.backend));
679− native::stop(child);
830+ (Process::Sandbox(step), _) => {
831+ // A launcher command still going is ended first, then the sandbox stopped.
832+ if let Some((_, child)) = step.as_mut() {
833+ native::stop(child);
834+ }
835+ let binary = native::binary(&folder(&self.app));
836+ if let Err(why) = launcher::down(&binary, &native_dir(&self.app, Backend::Sandboxed), how) {
837+ self.write_log(&format!("g1t: the sandbox didn't stop cleanly: {why}"));
838+ }
839+ self.app.state::<Shared>().now.lock().unwrap().sandbox = None;
680840 }
681− // The image's runner is always a container; a child here is only ever g1t-runner's.
682− (Process::Child(child), Engine::Sysbox) => native::stop(child),
841+ (Process::Child(child), Engine::Launcher) => native::stop(child),
683842 (Process::Child(child), Engine::Native { docker }) => {
684843 native::stop(child);
685844 // A job's container outlives the runner killed under it: removed now, not at its next start.
693852
694853 /// Stops whatever runs now.
695854 fn halt(&mut self) {
855+ self.halt_as(Down::Now);
856+ }
857+
858+ /// Stops whatever runs: the sandbox now, after its job, or for good.
859+ fn halt_as(&mut self, how: Down) {
696860 self.generation += 1;
697861 if let Some(mut run) = self.run.take() {
698− self.stop(&mut run);
862+ self.stop(&mut run, how);
699863 }
700864 }
701865
713877 let Some(registration) = Registration::load(&app) else { return };
714878 let binary = native::binary(&folder(&app));
715879 match registration.backend {
716− Backend::Sandboxed if launcher::READY => {
717− launcher::down(&binary, &native_dir(&app, Backend::Sandboxed));
718− let _ = std::fs::remove_dir_all(native_dir(&app, Backend::Sandboxed));
719− }
880+ // The sandbox keeps its registration for when it is chosen
881+ // again (sandbox-registration.json says whose): it only stops,
882+ // after its job.
720883 Backend::Sandboxed => {
721− if backend::docker_ready() {
722− container::forget();
723− }
884+ let _ = launcher::down(&binary, &native_dir(&app, Backend::Sandboxed), Down::Drain);
724885 }
725886 backend => native::forget(&binary, &native_dir(&app, backend)),
726887 }
727888 Registration::clear(&app);
728889 }
729890
730− /// Whether what the runner keeps of a registration is still here.
731− fn kept(&self, engine: Engine, backend: Backend) -> bool {
732− match engine {
733− Engine::Sysbox => container::registered(),
734− Engine::Launcher => native_dir(&self.app, backend).exists(),
735− Engine::Native { .. } => native_dir(&self.app, backend).join("config.json").exists(),
891+ /// Removes the sandbox and its registration: it was for another
892+ /// workspace, or g1t no longer knows its runners.
893+ fn forget_sandbox(&mut self) {
894+ let binary = native::binary(&folder(&self.app));
895+ if let Err(why) = launcher::down(&binary, &native_dir(&self.app, Backend::Sandboxed), Down::Delete) {
896+ self.write_log(&format!("g1t: the sandbox couldn't be removed: {why}"));
897+ }
898+ SandboxRegistration::clear(&self.app);
899+ if Registration::load(&self.app).is_some_and(|r| r.backend == Backend::Sandboxed) {
900+ Registration::clear(&self.app);
736901 }
737902 }
738903
904+ /// Whether what a native runner keeps of a registration is still here.
905+ fn kept(&self, backend: Backend) -> bool {
906+ native_dir(&self.app, backend).join("config.json").exists()
907+ }
908+
739909 /// Asks the site for a registration token for `workspace`.
740910 fn want(&mut self, workspace: &str) {
741911 *self.app.state::<Shared>().wanted.lock().unwrap() = Some(workspace.to_owned());
761931 if let Some(run) = &self.run {
762932 let moved = run.backend != settings.runner_backend || Some(&run.workspace) != workspace.as_ref();
763933 let back = run.offline_at.is_some_and(|at| at.elapsed() >= BACK_ONLINE);
764− let (busy, listening) = (run.busy, run.listening);
934+ let (busy, listening, stepping) = (run.busy, run.listening, run.step().is_some());
765935 if back {
766936 if let Some(run) = self.run.as_mut() {
767937 run.offline_at = None;
769939 self.set(Code::Idle, Extra::default());
770940 }
771941 // A change of backend or workspace waits for the job to end, as an update does.
772− if moved && !busy {
773− self.halt();
942+ if moved && !busy && !stepping {
943+ self.halt_as(Down::Drain);
774944 self.not_before = Instant::now();
775− } else if !busy && listening && Instant::now() >= self.next_update {
945+ } else if !busy && listening && !stepping && Instant::now() >= self.next_update {
776946 self.update();
777947 }
948+ self.poll();
778949 return;
779950 }
780951 if Instant::now() < self.not_before {
791962 self.not_before = Instant::now() + LOOK_AGAIN;
792963 let code = match missing {
793964 Missing::Docker => Code::NoDocker,
794− Missing::Sandbox => Code::NoSandbox,
795− Missing::Sysbox => Code::NoSysbox,
796965 };
797966 self.set(code, Extra::default());
798967 return;
808977 self.set(code, detail("Waiting for g1t to open."));
809978 return;
810979 };
980+ if engine == Engine::Launcher {
981+ return self.start_sandbox(workspace);
982+ }
811983 let server = config::server(&app);
812984 let registration = Registration::load(&app);
813− let ours = registration.as_ref().is_some_and(|r| r.server == server.as_str() && r.workspace == workspace && r.backend == backend) && self.kept(engine, backend);
985+ let ours = registration.as_ref().is_some_and(|r| r.server == server.as_str() && r.workspace == workspace && r.backend == backend) && self.kept(backend);
814986 let mut token = None;
815987 if !ours {
816988 if registration.is_some() {
822994 _ => return self.want(&workspace),
823995 }
824996 }
825− // The setting names the workspace from now on, so browsing another one never moves the runner.
826− if config::settings(&app).runner_workspace.as_deref() != Some(workspace.as_str()) {
827− let mut settings = config::settings(&app);
828− settings.runner_workspace = Some(workspace.clone());
829− let _ = config::set_settings(&app, settings);
830− }
997+ self.hold_workspace(&workspace);
831998 self.set(Code::Starting, Extra::default());
832999 let envs = environment(&server, backend, token.as_deref());
8331000 let generation = self.generation + 1;
8341001 self.generation = generation;
835− let started = match engine {
836− Engine::Sysbox => {
837− if !container::have_image() {
838− self.set(Code::Updating, detail("Downloading the runner's image."));
839− container::pull();
840− }
841− container::start(&envs).map(Process::Container)
842− }
843− Engine::Launcher | Engine::Native { .. } => self.ensure_binary(&server).and_then(|binary| {
844− let dir = native_dir(&app, backend);
845− let child = if engine == Engine::Launcher {
846− let mut command = launcher::up(&binary, &dir);
847− command.envs(envs.iter().map(|(name, value)| (*name, value.as_str())));
848− command.spawn()
849− } else {
850− native::spawn(&binary, &dir, &envs)
851− };
852− let child = child.map_err(|e| format!("g1t-runner could not start: {e}"))?;
853− let _ = std::fs::write(folder(&app).join("pid"), child.id().to_string());
854− Ok(Process::Child(child))
855− }),
856− };
1002+ let started = self.ensure_binary(&server).and_then(|binary| {
1003+ let child = native::spawn(&binary, &native_dir(&app, backend), &envs).map_err(|e| format!("g1t-runner could not start: {e}"))?;
1004+ let _ = std::fs::write(folder(&app).join("pid"), child.id().to_string());
1005+ Ok(child)
1006+ });
8571007 match started {
858− Ok(mut process) => {
859− let child = match &mut process {
860− Process::Child(child) | Process::Container(child) => child,
861− };
862− self.follow(generation, child);
863− self.run = Some(Run {
864− generation,
865− engine,
866− backend,
867− workspace,
868− process,
869− listening: false,
870− busy: false,
871− offline_at: None,
872− with_token: token.is_some(),
873− forgotten: false,
874− token_refused: false,
875− error: None,
876− });
1008+ Ok(mut child) => {
1009+ self.follow(generation, &mut child);
1010+ self.run = Some(Run::new(generation, engine, backend, workspace, Process::Child(child), token.is_some()));
8771011 self.set(Code::Starting, Extra::default());
8781012 }
879− Err(why) => {
880− // The token is kept for the next try, while it lasts.
881− if let Some(given) = token {
882− self.token = Some((workspace, given));
883− }
884− self.failures += 1;
885− self.not_before = Instant::now() + LOOK_AGAIN;
886− let offline = why.contains("error sending request") || why.contains("dns") || why.contains("connect");
887− self.write_log(&format!("g1t: the runner could not start: {why}"));
888− if offline {
889− self.set(Code::Offline, Extra::default());
890− } else {
891− self.set(Code::Crashed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() });
892− }
893− }
1013+ Err(why) => self.not_started(workspace, token, why),
1014+ }
1015+ }
1016+
1017+ /// The runner couldn't be started: the token is kept for the next try,
1018+ /// while it lasts, and it is tried again in a while.
1019+ fn not_started(&mut self, workspace: String, token: Option<String>, why: String) {
1020+ if let Some(given) = token {
1021+ self.token = Some((workspace, given));
1022+ }
1023+ self.failures += 1;
1024+ self.not_before = Instant::now() + LOOK_AGAIN;
1025+ let offline = why.contains("error sending request") || why.contains("dns") || why.contains("connect");
1026+ self.write_log(&format!("g1t: the runner could not start: {why}"));
1027+ if offline {
1028+ self.set(Code::Offline, Extra::default());
1029+ } else {
1030+ self.set(Code::Crashed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() });
8941031 }
8951032 }
8961033
1034+ /// The setting names the workspace from now on, so browsing another one never moves the runner.
1035+ fn hold_workspace(&self, workspace: &str) {
1036+ if config::settings(&self.app).runner_workspace.as_deref() != Some(workspace) {
1037+ let mut settings = config::settings(&self.app);
1038+ settings.runner_workspace = Some(workspace.to_owned());
1039+ let _ = config::set_settings(&self.app, settings);
1040+ }
1041+ }
1042+
8971043 /// Reads the child's output, a line at a time, onto the thread's channel.
8981044 fn follow(&self, generation: u64, child: &mut Child) {
8991045 fn pipe(tx: Sender<Ask>, generation: u64, from: impl Read + Send + 'static, last: bool) {
9001046 std::thread::spawn(move || {
9011047 for line in BufReader::new(from).lines().map_while(Result::ok) {
902− if tx.send(Ask::Line(generation, line)).is_err() {
1048+ if tx.send(Ask::Line(generation, line, last)).is_err() {
9031049 return;
9041050 }
9051051 }
9401086 return;
9411087 }
9421088 let Some(engine) = self.run.as_ref().map(|run| run.engine) else { return };
943− match engine {
944− Engine::Sysbox => {
945− if container::pull() || container::outdated() {
946− self.set(Code::Updating, detail("Starting the newest runner image."));
947− self.halt();
948− self.not_before = Instant::now();
949− }
1089+ let server = config::server(&self.app);
1090+ let folder = folder(&self.app);
1091+ let release = match self.newer_release(&server) {
1092+ Ok(release) => release,
1093+ Err(why) => {
1094+ let why = format!("g1t-runner couldn't be updated: {why}");
1095+ self.write_log(&format!("g1t: {why}"));
1096+ self.update_failed = Some(why.clone());
1097+ return self.set(Code::UpdateFailed, detail(why));
1098+ }
1099+ };
1100+ let Some((version, bytes)) = release else {
1101+ // The program is the newest; the sandbox's image may not be
1102+ // (`update` does nothing when it is).
1103+ if engine == Engine::Launcher {
1104+ self.sandbox_step(Step::Update);
1105+ }
1106+ return;
1107+ };
1108+ self.set(Code::Updating, detail(format!("Updating g1t-runner to {version}.")));
1109+ // The sandbox's runner for Windows jobs is this program too: it
1110+ // stops, after its job, before the program is replaced.
1111+ self.halt_as(Down::Drain);
1112+ match native::put(&folder, &bytes, &version) {
1113+ Ok(()) => {
1114+ self.note(None, Some(version));
1115+ self.update_failed = None;
1116+ // The sandbox's image comes with each release.
1117+ self.image_pending = engine == Engine::Launcher;
1118+ }
1119+ Err(why) => self.write_log(&format!("g1t: g1t-runner {version} couldn't be put in place: {why}")),
1120+ }
1121+ self.not_before = Instant::now();
1122+ }
1123+
1124+ /// A release newer than the `g1t-runner` here, downloaded and checked
1125+ /// against its SHA-256: its version and program. None when this one is
1126+ /// the newest, or the releases can't be asked now.
1127+ fn newer_release(&self, server: &Url) -> Result<Option<(String, Vec<u8>)>, String> {
1128+ let Ok(manifest) = native::latest(server) else { return Ok(None) };
1129+ if !native::installed(&folder(&self.app)).is_none_or(|have| native::newer(&manifest.version, &have)) {
1130+ return Ok(None);
1131+ }
1132+ let bytes = native::download(server, &manifest).map_err(|why| format!("{} didn't download: {why}", manifest.version))?;
1133+ Ok(Some((manifest.version, bytes)))
1134+ }
1135+
1136+ // --- The sandbox (launcher.rs) ------------------------------------------------
1137+
1138+ /// Starts the sandbox for `workspace`: registered with it the first
1139+ /// time, with a token from the site; from its own registration after.
1140+ fn start_sandbox(&mut self, workspace: String) {
1141+ let app = self.app.clone();
1142+ let server = config::server(&app);
1143+ let binary = match self.ensure_binary(&server) {
1144+ Ok(binary) => binary,
1145+ Err(why) => return self.not_started(workspace, None, why),
1146+ };
1147+ let dir = native_dir(&app, Backend::Sandboxed);
1148+ let mut status = match launcher::status(&binary, &dir) {
1149+ Ok(status) => status,
1150+ Err(why) => return self.no_status(why),
1151+ };
1152+ self.seen(&status);
1153+ // Nothing here can be sandboxed: why, and how to fix it; looked at again in a while.
1154+ if !status.anything_available() {
1155+ self.not_before = Instant::now() + LOOK_AGAIN;
1156+ let (code, extra) = shown(&status);
1157+ return self.set(code, extra);
1158+ }
1159+ // Another backend's registration goes; the sandbox's own is kept for its workspace.
1160+ if Registration::load(&app).is_some_and(|r| r.backend != Backend::Sandboxed) {
1161+ self.set(Code::Starting, detail("Removing this computer's earlier registration."));
1162+ self.forget();
1163+ }
1164+ let kept = SandboxRegistration::load(&app);
1165+ let ours = kept.as_ref().is_some_and(|k| k.server == server.as_str() && k.workspace == workspace);
1166+ let made = status.capabilities.iter().any(|c| c.state != State::Absent) || status.state != State::Absent;
1167+ if !ours && made && kept.is_none() {
1168+ // Made by hand (`g1t-runner sandbox up`), and there is one per computer: it is the person's to remove.
1169+ self.not_before = Instant::now() + LOOK_AGAIN;
1170+ return self.set(Code::SandboxTaken, Extra::default());
1171+ }
1172+ if !ours && made {
1173+ // The app's sandbox, registered with another workspace or server.
1174+ self.set(Code::Starting, detail("Removing this computer's sandbox for another workspace."));
1175+ self.forget_sandbox();
1176+ status = match launcher::status(&binary, &dir) {
1177+ Ok(status) => status,
1178+ Err(why) => return self.no_status(why),
1179+ };
1180+ }
1181+ let mut token = None;
1182+ if !ours || !kept.as_ref().is_some_and(|k| k.registered) || status.unregistered() {
1183+ match self.token.take() {
1184+ Some((for_workspace, given)) if for_workspace == workspace => token = Some(given),
1185+ _ => return self.want(&workspace),
1186+ }
1187+ SandboxRegistration { server: server.to_string(), workspace: workspace.clone(), registered: false }.save(&app);
1188+ }
1189+ self.hold_workspace(&workspace);
1190+ let generation = self.generation + 1;
1191+ self.generation = generation;
1192+ self.run = Some(Run::new(generation, Engine::Launcher, Backend::Sandboxed, workspace.clone(), Process::Sandbox(None), token.is_some()));
1193+ // A new release's image first, then the sandbox starts again.
1194+ let step = if self.image_pending && status.linux().state != State::Absent { Step::Update } else { Step::Up };
1195+ self.image_pending = false;
1196+ let name = backend::machine_name();
1197+ let url = server.as_str().trim_end_matches('/').to_owned();
1198+ let up = launcher::Up { url: &url, name: &name, labels: "desktop", token: token.as_deref() };
1199+ let args = match step {
1200+ Step::Up => launcher::up_args(&dir, &up),
1201+ Step::Update => launcher::update_args(&dir),
1202+ };
1203+ if let Err(why) = self.spawn_step(step, args) {
1204+ self.run = None;
1205+ return self.not_started(workspace, token, why);
1206+ }
1207+ self.set(Code::Starting, detail(STARTING_SANDBOX));
1208+ }
1209+
1210+ /// `sandbox status` didn't answer: a `g1t-runner` from before its
1211+ /// launcher, which a newer release replaces, or one that failed.
1212+ fn no_status(&mut self, why: String) {
1213+ self.write_log(&format!("g1t: the sandbox's status: {why}"));
1214+ self.failures += 1;
1215+ if why.contains("older than its sandbox launcher") {
1216+ let server = config::server(&self.app);
1217+ if let Ok(Some((version, bytes))) = self.newer_release(&server)
1218+ && native::put(&folder(&self.app), &bytes, &version).is_ok()
1219+ {
1220+ self.note(None, Some(version));
1221+ self.not_before = Instant::now();
1222+ return;
1223+ }
1224+ self.not_before = Instant::now() + MOST_BACKOFF;
1225+ return self.set(Code::NoLauncher, detail(why));
1226+ }
1227+ self.not_before = Instant::now() + LOOK_AGAIN;
1228+ self.set(Code::SandboxFailed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() });
1229+ }
1230+
1231+ /// Runs a launcher command for the sandbox's run, reading its output.
1232+ fn spawn_step(&mut self, step: Step, args: Vec<std::ffi::OsString>) -> Result<(), String> {
1233+ let binary = native::binary(&folder(&self.app));
1234+ let mut child = launcher::command(&binary, args).spawn().map_err(|e| format!("g1t-runner sandbox {} could not start: {e}", step.name()))?;
1235+ let _ = std::fs::write(folder(&self.app).join("pid"), child.id().to_string());
1236+ let Some(generation) = self.run.as_ref().map(|run| run.generation) else {
1237+ native::stop(&mut child);
1238+ return Ok(());
1239+ };
1240+ self.follow(generation, &mut child);
1241+ let run = self.run.as_mut().expect("checked above");
1242+ run.json = None;
1243+ run.process = Process::Sandbox(Some((step, child)));
1244+ Ok(())
1245+ }
1246+
1247+ /// `update` on the running sandbox, while it has no job.
1248+ fn sandbox_step(&mut self, step: Step) {
1249+ if self.run.as_ref().is_none_or(|run| run.step().is_some()) {
1250+ return;
1251+ }
1252+ let dir = native_dir(&self.app, Backend::Sandboxed);
1253+ if let Err(why) = self.spawn_step(step, launcher::update_args(&dir)) {
1254+ self.write_log(&format!("g1t: {why}"));
1255+ }
1256+ }
1257+
1258+ /// Asks the sandbox how it is, every few seconds, on a thread of its own.
1259+ fn poll(&mut self) {
1260+ let Some(run) = self.run.as_mut().filter(|run| run.engine == Engine::Launcher) else { return };
1261+ if run.polling || run.polled_at.is_some_and(|at| at.elapsed() < launcher::STATUS_EVERY) {
1262+ return;
1263+ }
1264+ run.polling = true;
1265+ run.polled_at = Some(Instant::now());
1266+ let (tx, generation) = (self.tx.clone(), run.generation);
1267+ let binary = native::binary(&folder(&self.app));
1268+ let dir = native_dir(&self.app, Backend::Sandboxed);
1269+ let spawned = std::thread::Builder::new().name("g1t-sandbox-status".into()).spawn(move || {
1270+ let _ = tx.send(Ask::Sandbox(generation, launcher::status(&binary, &dir).map(Box::new)));
1271+ });
1272+ if spawned.is_err() {
1273+ run.polling = false;
1274+ }
1275+ }
1276+
1277+ /// What a poll said.
1278+ fn polled(&mut self, generation: u64, status: Result<Box<Status>, String>) {
1279+ let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
1280+ run.polling = false;
1281+ if std::mem::take(&mut run.stale) {
1282+ return;
1283+ }
1284+ match status {
1285+ Ok(status) => self.sandbox_now(*status),
1286+ // Once is nothing: the next poll asks again.
1287+ Err(why) => self.write_log(&format!("g1t: the sandbox's status: {why}")),
1288+ }
1289+ }
1290+
1291+ /// Keeps what a status says for the tray, settings and the site.
1292+ fn seen(&mut self, status: &Status) {
1293+ let shared = self.app.state::<Shared>();
1294+ let mut now = shared.now.lock().unwrap();
1295+ now.sandbox = Some(status.clone());
1296+ if let Some(version) = status.version() {
1297+ now.version = Some(version);
1298+ }
1299+ now.job = status.job().map(launcher::Job::said);
1300+ }
1301+
1302+ /// The sandbox as it is now, while it is the run.
1303+ fn sandbox_now(&mut self, status: Status) {
1304+ let had = self.app.state::<Shared>().now.lock().unwrap().job.clone();
1305+ let Some(run) = self.run.as_mut() else { return };
1306+ let step = run.step();
1307+ let job = status.job().map(launcher::Job::said);
1308+ let was = had.filter(|_| run.busy);
1309+ run.busy = job.is_some();
1310+ run.listening = status.running();
1311+ let update_failed = self.update_failed.clone();
1312+ if was != job {
1313+ if let Some(ended) = &was {
1314+ self.write_log(&format!("g1t: {ended} ended."));
1315+ }
1316+ if let Some(took) = &job {
1317+ self.write_log(&format!("g1t: Took {took}."));
9501318 }
951− Engine::Launcher | Engine::Native { .. } => {
952− let server = config::server(&self.app);
953− let folder = folder(&self.app);
954− let Ok(manifest) = native::latest(&server) else { return };
955− if !native::installed(&folder).is_none_or(|have| native::newer(&manifest.version, &have)) {
956− return;
1319+ }
1320+ self.seen(&status);
1321+ match step {
1322+ Some(Step::Up) => return self.set(Code::Starting, detail(STARTING_SANDBOX)),
1323+ Some(Step::Update) if status.state == State::Updating => return self.set(Code::Updating, detail("Updating the sandbox to the newest image.")),
1324+ _ => {}
1325+ }
1326+ let (code, extra) = shown(&status);
1327+ if step.is_none() && !status.running() && !matches!(status.state, State::Starting | State::Updating) {
1328+ return self.sandbox_stopped(&status, code, extra);
1329+ }
1330+ match (code, update_failed) {
1331+ (Code::Idle, Some(why)) => self.set(Code::UpdateFailed, detail(why)),
1332+ (code, _) => self.set(code, extra),
1333+ }
1334+ }
1335+
1336+ /// The sandbox stopped by itself, or never got going: started again,
1337+ /// waiting longer each time, or registered again when that is why.
1338+ fn sandbox_stopped(&mut self, status: &Status, code: Code, extra: Extra) {
1339+ let error = status.error.clone().or_else(|| extra.detail.clone()).unwrap_or_default();
1340+ if self.registration_trouble(&error) {
1341+ return;
1342+ }
1343+ self.run = None;
1344+ self.generation += 1;
1345+ self.failures += 1;
1346+ let wait = Duration::from_secs(15 * 2u64.pow(self.failures.min(6))).min(MOST_BACKOFF);
1347+ self.not_before = Instant::now() + wait;
1348+ let (code, extra) = match code {
1349+ Code::Idle | Code::Busy | Code::Starting | Code::Updating => (Code::SandboxFailed, Extra { detail: status.error.clone().or_else(|| some_text("The sandbox stopped by itself.")), ..Extra::default() }),
1350+ code => (code, extra),
1351+ };
1352+ self.set(code, Extra { wait_seconds: Some(wait.as_secs()), ..extra });
1353+ }
1354+
1355+ /// When the sandbox's trouble is its registration: true when the app
1356+ /// has seen to it, and the next start registers again.
1357+ fn registration_trouble(&mut self, error: &str) -> bool {
1358+ match launcher::why(error) {
1359+ launcher::Why::TokenRefused | launcher::Why::Unregistered => {
1360+ if let Some(mut kept) = SandboxRegistration::load(&self.app) {
1361+ kept.registered = false;
1362+ kept.save(&self.app);
9571363 }
958− let bytes = match native::download(&server, &manifest) {
959− Ok(bytes) => bytes,
960− Err(why) => {
961− let why = format!("g1t-runner {} couldn't be installed: {why}", manifest.version);
962− self.write_log(&format!("g1t: {why}"));
963− return self.set(Code::UpdateFailed, detail(why));
964− }
965− };
966− self.set(Code::Updating, detail(format!("Updating g1t-runner to {}.", manifest.version)));
9671364 self.halt();
968− match native::put(&folder, &bytes, &manifest.version) {
969− Ok(()) if engine == Engine::Launcher => {
970− let backend = config::settings(&self.app).runner_backend;
971− launcher::update(&native::binary(&folder), &native_dir(&self.app, backend));
972− }
973− Ok(()) => {}
974− Err(why) => self.write_log(&format!("g1t: g1t-runner {} couldn't be put in place: {why}", manifest.version)),
975− }
9761365 self.not_before = Instant::now();
1366+ true
1367+ }
1368+ launcher::Why::Forgotten => {
1369+ self.halt();
1370+ self.forget_sandbox();
1371+ self.not_before = Instant::now();
1372+ true
1373+ }
1374+ _ => false,
1375+ }
1376+ }
1377+
1378+ /// A launcher command ended: what it printed last is the sandbox's status.
1379+ fn launched(&mut self, generation: u64) {
1380+ let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return };
1381+ let Process::Sandbox(slot) = &mut run.process else { return };
1382+ let Some((step, mut child)) = slot.take() else { return };
1383+ let worked = child.wait().is_ok_and(|status| status.success());
1384+ let _ = std::fs::remove_file(folder(&self.app).join("pid"));
1385+ let printed = run.json.take().unwrap_or_default().join("\n");
1386+ run.stale = run.polling;
1387+ let with_token = run.with_token;
1388+ let status = launcher::parse(&printed);
1389+ let error = match (&status, worked) {
1390+ (Ok(status), true) => status.error.clone().filter(|_| !status.running()),
1391+ (Ok(status), false) => Some(status.error.clone().unwrap_or_else(|| format!("g1t-runner sandbox {} failed.", step.name()))),
1392+ (Err(why), _) => Some(why.clone()),
1393+ };
1394+ let status = status.ok();
1395+ let running = status.as_ref().is_some_and(Status::running);
1396+ if let Some(error) = &error {
1397+ self.write_log(&format!("g1t: sandbox {}: {error}", step.name()));
1398+ }
1399+ // Registered: from now on it starts with no token.
1400+ if step == Step::Up && with_token && (error.is_none() || running) {
1401+ let app = self.app.clone();
1402+ if let Some(run) = self.run.as_mut() {
1403+ run.with_token = false;
1404+ let registration = Registration { server: config::server(&app).to_string(), workspace: run.workspace.clone(), backend: Backend::Sandboxed };
1405+ SandboxRegistration { server: registration.server.clone(), workspace: registration.workspace.clone(), registered: true }.save(&app);
1406+ registration.save(&app);
1407+ }
1408+ }
1409+ if let Some(error) = error.clone() {
1410+ if self.registration_trouble(&error) {
1411+ return;
1412+ }
1413+ if step == Step::Update {
1414+ self.update_failed = Some(format!("The sandbox's image couldn't be updated: {error}"));
1415+ } else if !running {
1416+ // Nothing runs: tidied away, and tried again in a while.
1417+ self.halt();
1418+ self.failures += 1;
1419+ let wait = Duration::from_secs(15 * 2u64.pow(self.failures.min(6))).min(MOST_BACKOFF);
1420+ self.not_before = Instant::now() + wait;
1421+ let (code, cause, fix) = status
1422+ .as_ref()
1423+ .and_then(diagnosis::sandbox_trouble)
1424+ .unwrap_or_else(|| (diagnosis::sandbox_code(launcher::why(&error)), error.clone(), None));
1425+ return self.set(code, Extra { detail: Some(cause), fix, wait_seconds: Some(wait.as_secs()), ..Extra::default() });
9771426 }
1427+ } else if step == Step::Up {
1428+ self.failures = 0;
1429+ } else {
1430+ self.update_failed = None;
9781431 }
1432+ if step == Step::Update && !running {
1433+ // Updated while stopped: it starts again with the new image.
1434+ self.run = None;
1435+ self.generation += 1;
1436+ self.not_before = Instant::now();
1437+ return;
1438+ }
1439+ if let Some(status) = status {
1440+ self.sandbox_now(status);
1441+ }
9791442 }
9801443 }
9811444
1445+/// What the tray says while the sandbox starts.
1446+const STARTING_SANDBOX: &str = "Starting the sandbox. The first time, it downloads and checks the sandbox's image, which takes a few minutes.";
1447+
1448+fn some_text(text: &str) -> Option<String> {
1449+ Some(text.to_owned())
1450+}
1451+
1452+/// What the app shows for how the sandbox is, with no launcher command
1453+/// running: what is wrong with Linux jobs first, else what its runners do.
1454+fn shown(status: &Status) -> (Code, Extra) {
1455+ if let Some((code, cause, fix)) = diagnosis::sandbox_trouble(status) {
1456+ return (code, Extra { detail: Some(cause), fix, ..Extra::default() });
1457+ }
1458+ let linux = status.linux();
1459+ match (linux.state, status.job()) {
1460+ (_, Some(_)) => (Code::Busy, Extra::default()),
1461+ (State::Running, None) => (Code::Idle, Extra::default()),
1462+ (State::Starting, None) => (Code::Starting, detail(STARTING_SANDBOX)),
1463+ (State::Updating, None) => (Code::Updating, detail("Updating the sandbox to the newest image.")),
1464+ // Linux jobs aren't running, but Windows jobs are.
1465+ _ if status.running() => (Code::Idle, Extra::default()),
1466+ _ => (Code::SandboxFailed, detail("The sandbox isn't running.")),
1467+ }
1468+}
1469+
9821470 /// What `register-and-run` reads: where g1t is, the runner's name and
9831471 /// labels, and the token, the first time only. The app updates the runner,
9841472 /// so the runner does not update itself; registering again takes the
10451533 assert!(!again.iter().any(|(name, _)| *name == "G1T_RUNNER_TOKEN" || *name == "G1T_RUNNER_NO_DOCKER"));
10461534 }
10471535
1536+ fn status(json: &str) -> Status {
1537+ launcher::parse(json).unwrap()
1538+ }
1539+
1540+ #[test]
1541+ fn the_sandbox_shows_what_its_runners_do_and_linux_trouble_first() {
1542+ let running = status(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running"},{"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not)."}]}"#);
1543+ assert_eq!(shown(&running).0, Code::Idle);
1544+ let busy = status(r#"{"schema":1,"state":"running","job":{"kind":"workflow","id":"j","name":"build","repo":"acme/web"}}"#);
1545+ assert_eq!(shown(&busy).0, Code::Busy);
1546+ let starting = status(r#"{"schema":1,"state":"starting","capabilities":[{"id":"linux","available":true,"state":"starting"}]}"#);
1547+ assert_eq!(shown(&starting).0, Code::Starting);
1548+ let updating = status(r#"{"schema":1,"state":"updating","capabilities":[{"id":"linux","available":true,"state":"updating"}]}"#);
1549+ assert_eq!(shown(&updating).0, Code::Updating);
1550+ // Windows jobs run while WSL is missing: what is wrong with Linux jobs is what it says.
1551+ let no_wsl = status(r#"{"schema":1,"state":"error","error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.","capabilities":[
1552+ {"id":"linux","available":false,"state":"error","cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.","fix":"Open a terminal as administrator and run `wsl --install --no-distribution`."},
1553+ {"id":"windows","available":true,"state":"running"}]}"#);
1554+ let (code, extra) = shown(&no_wsl);
1555+ assert_eq!(code, Code::NoWsl);
1556+ assert!(extra.detail.unwrap().starts_with("WSL is not installed"));
1557+ let failed = status(r#"{"schema":1,"state":"error","error":"the sandbox did not start within 180 seconds","capabilities":[{"id":"linux","available":true,"state":"error"}]}"#);
1558+ let (code, extra) = shown(&failed);
1559+ assert_eq!((code, extra.detail.as_deref()), (Code::SandboxFailed, Some("the sandbox did not start within 180 seconds")));
1560+ let stopped = status(r#"{"schema":1,"state":"stopped","capabilities":[{"id":"linux","available":true,"state":"stopped"},{"id":"windows","available":true,"state":"stopped"}]}"#);
1561+ assert_eq!(shown(&stopped).0, Code::SandboxFailed);
1562+ }
1563+
10481564 #[test]
10491565 fn the_site_names_workspaces_in_its_own_words() {
10501566 let offered: Vec<Offered> = serde_json::from_str(r#"[{"slug":"acme","name":"Acme"},{"slug":"solo"}]"#).unwrap();
+62−33
163163 runner has `self-hosted`, its OS and architecture (`linux` for a runner
164164 whose jobs run in containers, whatever the computer is), and `desktop`.
165165 It is named after your computer, and a new registration from the same
166−computer takes the place of the old one.
166+computer takes the place of the old one. Sandboxed, the computer has a
167+runner for each kind of job it can run, named after it with `-linux` or
168+`-windows`: `build-box-linux` has `linux`, and `build-box-windows` has
169+`windows`, on a Windows computer with Windows Sandbox.
167170
168171 ### How jobs run
169172
173176
174177 | Choice | How | [Isolation](/guides/self-hosted-runners/#isolation) | What a job can reach |
175178 | --- | --- | --- | --- |
176−| **Sandboxed** (the default) | The runner in a sandbox of its own, and each job in a container inside that. On Linux, the runner's image under [Sysbox](https://github.com/nestybox/sysbox), through your Docker, without its socket. On Windows and macOS, a small virtual machine the app makes, once that is released; until then it says *a sandbox isn't available on this computer yet*. | `nested-container` | Its own container. Not your computer. |
179+| **Sandboxed** (the default) | [The sandbox](/guides/self-hosted-runners/#sandbox-on-your-computer), which the app sets up and starts itself. Linux jobs run in a small Linux VM of the runner's own, each in a container inside it: a WSL 2 distribution on Windows, a VM under Apple's Virtualization framework on macOS, and on Linux the runner's image under [Sysbox](https://github.com/nestybox/sysbox), or a VM under QEMU with KVM. On Windows 11 Pro, Enterprise and Education, Windows jobs run too, each in a Windows Sandbox of its own. | `nested-container`; `vm` for Windows jobs | Its own container, or its own Windows Sandbox. Not your computer. |
177180 | **In Docker** | `g1t-runner` on your computer, driving your Docker. Each job is a container of its own. | `container` | Its own container; but the runner controls your Docker, which can reach your files (Docker Desktop shares your home folder with its VM), so this is not a sandbox. |
178181 | **Directly on this computer** | `g1t-runner` on your computer, each job a process in a folder of its own. For jobs that need the system itself, such as building a Windows installer. | `host` | Everything your account can: your files, your keys, what an earlier job left. Not a sandbox. Never given a public repository's work. |
179182
180−On Linux, the sandboxed runner is this container, which the app starts,
181−replaces and stops itself:
183+What the sandbox needs on each computer:
184+
185+| Computer | Linux jobs | Windows jobs |
186+| --- | --- | --- |
187+| Windows 11, any edition | WSL, installed once: `wsl --install --no-distribution` in an admin terminal, then a restart. The app's **Copy command** button copies it. | Windows Sandbox, on Pro, Enterprise and Education, version 24H2 or later, turned on in Windows features. Home has none, and Linux jobs still run. |
188+| macOS 13 or later | [vfkit](https://github.com/crc-org/vfkit): `brew install vfkit` | Not released yet |
189+| Linux | Sysbox 0.7 or later in Docker, or QEMU with KVM | None: Linux jobs are native |
182190
183−```sh
184−docker run -d --name g1t-desktop-runner --runtime=sysbox-runc \
185− -v g1t-desktop-runner:/data \
186− -e G1T_RUNNER_URL -e G1T_RUNNER_NAME -e G1T_RUNNER_LABELS=desktop … \
187− g1t.sh/flagon-io/g1t-runner:latest register-and-run
188−```
191+The sandbox also needs virtualization turned on in your computer's
192+firmware (Intel VT-x or AMD-V), which most computers have on.
189193
190−Its registration is kept on the `g1t-desktop-runner` volume. For the
191−other two choices the app keeps `g1t-runner` in its data folder
194+For the sandbox, the app runs the same commands you would, with its own
195+copy of `g1t-runner` and its state in the app's data folder
192196 (`%APPDATA%\sh.g1t.desktop\runner` on Windows,
193197 `~/Library/Application Support/sh.g1t.desktop/runner` on macOS,
194−`~/.local/share/sh.g1t.desktop/runner` on Linux), with each choice's
195−registration beside it.
198+`~/.local/share/sh.g1t.desktop/runner` on Linux):
199+
200+| When | The app runs |
201+| --- | --- |
202+| A few seconds after g1t opens | `g1t-runner sandbox up --json --state-dir <data folder>/sandbox --url <server> --name <computer> --labels desktop`, with `--token` the first time only. The first `up` downloads and checks the sandbox's image and makes the VM, which takes a few minutes. |
203+| Every few seconds while it runs | `g1t-runner sandbox status --json`, for the tray, the settings and the Runners page. |
204+| When g1t quits, or you press **Stop** | `g1t-runner sandbox down`: a job that is running is cut short. |
205+| When you choose another way of running jobs | `g1t-runner sandbox down --drain`, once the job that is running ends. The sandbox keeps its registration, so choosing Sandboxed again needs no new one. |
206+| With a new release, while it has no job | `g1t-runner sandbox update`: the newest signed image, the same registration. |
207+
208+The other two choices run `g1t-runner` from the same data folder, with
209+each one's registration beside it.
210+
211+On Windows the sandbox is the WSL distribution `g1t-sandbox`, of which a
212+computer has one. If you made one yourself with `g1t-runner sandbox up`,
213+the app leaves it alone and says *this computer has another sandbox*:
214+remove it with `g1t-runner sandbox down --delete` for the app to make its
215+own, or keep it and turn the app's runner off.
196216
197217 ### Registering
198218
207227 The workspace is the one you were in when it first registered; choose
208228 another under **Workspace** in the settings. Changing it, or how jobs
209229 run, waits for a job that is running to end, then removes the old
210−registration and makes a new one.
230+registration and makes a new one. The sandbox is registered with one
231+workspace, so moving it to another removes it and makes it again, image
232+and all; its old runners stay listed on g1t, offline, until you remove
233+them there.
211234
212235 ### Keeping it up to date
213236
214237 The app keeps the runner up to date: soon after it starts and every six
215238 hours, while the runner has no job, it looks for a newer `g1t-runner` in
216−`g1t.sh/downloads/runner/latest.json` (or a newer image, for the
217−sandbox), downloads it, checks it against the release's SHA-256, and puts
218−it in place. A job is never cut short for an update, and one that fails
219−leaves the runner as it was. Turning off **Download updates by
220−themselves** turns these off too.
239+`g1t.sh/downloads/runner/latest.json`, downloads it, checks it against
240+the release's SHA-256, and puts it in place; the sandbox then takes the
241+release's image, whose signature and SHA-256 it checks itself. A job is
242+never cut short for an update, and one that fails leaves the runner as it
243+was. Turning off **Download updates by themselves** turns these off too.
221244
222245 ### Seeing it
223246
224247 | Where | What it shows |
225248 | --- | --- |
226249 | The tray | A row with what the runner is doing, *Runner: Waiting for jobs*, which opens the settings; **Stop running jobs** or **Start running jobs** under it. The icon's hover text says it too. |
227−| The settings window | What it is doing; when it isn't running, why and how to fix it, with the fix's buttons; how jobs run; the workspace; your computer's name and the runner's version; **View on g1t**; and the runner's recent log, with **Open the whole log**. |
228−| The workspace's [Runners page](/guides/self-hosted-runners/#the-runners-page) | **Desktops**: every computer with the app in the workspace, with what each is doing, or why it isn't running and the fix, in the same words. Inside the app, this computer comes first, live, with its buttons. A computer whose g1t is closed says *g1t is closed on* its name. |
250+| The settings window | What it is doing; when it isn't running, why and how to fix it, with the fix's buttons; sandboxed, a line for each kind of job, *Linux jobs: Waiting for jobs*, *Windows jobs: Not available* and why; how jobs run; the workspace; your computer's name and the runner's version; **View on g1t**; and the runner's recent log, with **Open the whole log**. |
251+| The workspace's [Runners page](/guides/self-hosted-runners/#the-runners-page) | **Desktops**: every computer with the app in the workspace, with what each is doing, or why it isn't running and the fix, in the same words, and each kind of job a sandboxed one runs. Inside the app, this computer comes first, live, with its buttons. A computer whose g1t is closed says *g1t is closed on* its name. |
229252
230253 ### When it isn't running
231254
243266 | *Not running: no workspace to run jobs for* | You don't own a workspace, and only owners add runners. | Ask an owner, or **Create a workspace**. |
244267 | *Couldn't register with* a workspace: *only its owners can* | You aren't an owner of it. | Ask an owner to make you one, or **Choose workspace**. |
245268 | *Couldn't register with* a workspace, with g1t's reason | g1t refused the registration, such as for too many tokens in an hour. | **Retry**; it also tries again by itself. |
246−| *Not running: Docker isn't running* | Running jobs in Docker, or the sandbox on Linux, needs Docker, and it isn't running or isn't installed. | **Open Docker** (Docker Desktop), or **Get Docker**. It starts by itself once Docker answers. |
247−| *Not running: a sandbox isn't available on this computer yet* | The sandbox for Windows and macOS isn't released. | Wait for it, or **Choose how jobs run**: in Docker or directly on this computer, neither of which is a sandbox. |
248−| *Not running: Sysbox isn't installed* | The sandbox on Linux needs Sysbox in your Docker. | **Install Sysbox**. |
269+| *Not running: Docker isn't running* | Running jobs in Docker needs Docker, and it isn't running or isn't installed. | **Open Docker** (Docker Desktop), or **Get Docker**. It starts by itself once Docker answers. |
270+| *Not running: WSL isn't installed* | Windows: Linux jobs run in a WSL distribution of the runner's own. | Run `wsl --install --no-distribution` in an admin terminal (**Copy command**), then restart Windows. Every edition of Windows 11 has it, Home included. |
271+| *Not running: WSL uses mirrored networking* | `networkingMode=mirrored` in `%USERPROFILE%\.wslconfig` lets Linux reach your computer's own `localhost`. | Remove it, run `wsl --shutdown`, then **Retry**. |
272+| *Not running: virtualization is off* | The sandbox is a small VM, and virtualization is off in your computer's firmware, or Virtual Machine Platform is off in Windows features. | Turn them on (**Open Windows features** on Windows), then restart. |
273+| *Not running: vfkit isn't installed* | macOS: the sandbox's VM runs under vfkit. | Run `brew install vfkit` (**Copy command**). It starts by itself once vfkit is there. |
274+| *Not running: no Sysbox or KVM* | Linux: the sandbox needs Sysbox in Docker, or QEMU with KVM. | **Install Sysbox**, or turn on virtualization and install QEMU. |
275+| *Not running: the sandbox's image didn't download* | The image couldn't be fetched, or its signature or SHA-256 wasn't the release's, with the reason. | It tries again by itself; check the connection, or **Report** it. |
276+| *Not running: the sandbox can't run here* | Something else the sandbox needs, in its own words. | What it says. |
277+| *Not running: the sandbox failed* | The sandbox stopped, or didn't start, with its reason. | It starts again by itself, waiting longer each time. **Retry**, or **View log**. |
278+| *Not running: this computer has another sandbox* | A sandbox you made with `g1t-runner sandbox up`; a computer has one. | Remove it with `g1t-runner sandbox down --delete`, then **Retry**; or keep it, and turn the app's runner off. |
279+| *Not running: this g1t-runner has no sandbox* | The app's `g1t-runner` is from before the sandbox. | It downloads a newer one by itself; **Retry** looks now. |
280+| *Windows jobs: Not available* | Windows Sandbox is off, this Windows is Home, or it is older than 24H2. Linux jobs still run. | On Pro, Enterprise or Education, turn on Windows Sandbox (**Open Windows features**) and restart, or update Windows. |
249281 | *Offline:* the server *unreachable* | Your computer can't reach g1t. | Check the connection; it carries on by itself. **Retry** tries now. |
250282 | *Update failed* | A new version couldn't be downloaded, or its SHA-256 wasn't the release's. | It keeps running the version it has. **Retry**, or **Report** it. |
251283 | *Runner stopped (exit code* N*)* | The runner ended by itself, with the reason from its log. | It starts again by itself, waiting longer each time. **Restart** now, or **View log**. |
307339
308340 ## What's not there yet
309341
310−- **The sandbox, from the app, on Windows and macOS.** The sandbox itself
311− is built: `g1t-runner sandbox up` makes a computer a self-hosted runner
312− that nothing a job runs can get out of, with Linux jobs in a Linux VM of
313− the runner's own and, on Windows 11 Pro and up, Windows jobs each in a
314− Windows Sandbox ([the sandbox](/guides/self-hosted-runners/#sandbox-on-your-computer)).
315− The app does not start it for you yet, so until it does, its runner on
316− Windows and macOS runs jobs only if you choose Docker or this computer
317− itself; see [how jobs run](#how-jobs-run).
342+- **Windows jobs on a Mac.** Sandboxed macOS jobs are designed, not
343+ released, so a Mac's sandbox runs Linux jobs only.
344+- **Choosing the sandbox's share of the computer.** It takes half the
345+ CPUs (at most 8) and a quarter of the memory (2 to 8 GB); the app has no
346+ setting for it yet.
318347 - **Agents on your desktop.** The runner takes workflow jobs, and agent
319348 work only if the workspace [sends it to its runners](/guides/self-hosted-runners/#agents-on-your-runners).
320349 - **Opening the conversation from a notification.** Clicking one brings
+5−1
391391 keeps the runner up to date. It runs jobs sandboxed by default
392392 (`nested-container`); running them in Docker (`container`) or directly
393393 on the computer (`host`) are settings you choose, and the app never falls
394−back to them. Its runner has the label `desktop` besides the usual ones.
394+back to them. Sandboxed, it is [the sandbox](#sandbox-on-your-computer),
395+which the app sets up with `g1t-runner sandbox up` and shows with
396+`sandbox status --json`: Linux jobs and, on Windows 11 Pro and up,
397+Windows jobs, each a runner of its own. Its runners have the label
398+`desktop` besides the usual ones.
395399
396400 ## Add a runner
397401
+1−1
7070 | Scratchpads | Tools, reports and agents anyone can build on a copy of the data, used in production only after review. | <Status is="coming" /> |
7171 | [The desktop app](/guides/desktop/) | g1t in a window of its own for Windows, macOS and Linux, with notifications from your system. | <Status is="preview" /> |
7272 | [The phone app](/guides/mobile/) | Chat with your team and its agents, live, with notifications on the phone; review pull requests. Android first, for g1t.sh or your own g1t; not in the stores yet. | <Status is="preview" /> |
73−| [A runner in the desktop app](/guides/desktop/#the-runner) | Your workspace's self-hosted jobs on your computer while g1t is open, registered and kept up to date by the app, sandboxed by default. The sandbox on Windows and macOS is coming; until then jobs there run in Docker or on the computer, if you choose. | <Status is="preview" /> |
73+| [A runner in the desktop app](/guides/desktop/#the-runner) | Your workspace's self-hosted jobs on your computer while g1t is open, registered and kept up to date by the app, sandboxed by default: Linux jobs in a sandbox of the runner's own on Windows, macOS and Linux, and Windows jobs in Windows Sandbox on Windows 11 Pro and up. | <Status is="preview" /> |
7474
7575 ## Agents
7676
+35−8
33
44 import type { DesktopBackend, DesktopDiagnosis, DesktopRunner } from "@g1t/contracts";
55
6−import { BACKEND_LABEL, type DesktopRunnerAction, desktopRunner, useDesktopRunner } from "../lib/desktop-runner";
6+import { BACKEND_LABEL, type DesktopRunnerAction, type DesktopRunnerCapability, desktopRunner, useDesktopRunner } from "../lib/desktop-runner";
77 import { EmptyState, TimeAgo } from "./ui";
88 import { Badge } from "./ui/badge";
99 import { Button } from "./ui/button";
2626 diagnosis: DesktopDiagnosis;
2727 /** This computer's fixes, which only work here. */
2828 actions: DesktopRunnerAction[];
29+ /** Sandboxed: each kind of job, Linux and Windows, with how it is (and here, its fixes). */
30+ capabilities: DesktopRunnerCapability[];
2931 here: boolean;
3032 reportedAt: string | null;
3133 };
4143 version: here.version,
4244 diagnosis: here.diagnosis,
4345 actions: here.diagnosis.actions,
46+ capabilities: here.diagnosis.capabilities ?? [],
4447 here: true,
4548 reportedAt: null,
4649 });
5457 version: desktop.version,
5558 diagnosis: desktop.open ? desktop.diagnosis : desktop.closed,
5659 actions: [],
60+ capabilities: desktop.open ? (desktop.diagnosis.capabilities ?? []).map((c) => ({ ...c, actions: [] })) : [],
5761 here: false,
5862 reportedAt: desktop.reportedAt,
5963 });
6165 return rows;
6266 }
6367
68+/** A diagnosis's fixes, as buttons: they work only on this computer, in the app. */
69+function Actions({ actions }: { actions: DesktopRunnerAction[] }) {
70+ if (actions.length === 0) return null;
71+ return (
72+ <div className="mt-2 flex flex-wrap gap-2">
73+ {actions.map((action, at) => (
74+ <Button key={action.id} size="xs" variant={at === 0 ? "secondary" : "outline"} onClick={() => void desktopRunner()?.act(action.id).catch(() => {})}>
75+ {action.label}
76+ </Button>
77+ ))}
78+ </div>
79+ );
80+}
81+
6482 /**
6583 * The workspace's desktops: each person's computer with the g1t desktop
6684 * app, whose runner runs while g1t is open there. What each says, why it
95113 <p className="mt-1 text-sm text-fg">{row.diagnosis.title}</p>
96114 {row.diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{row.diagnosis.cause}</p>}
97115 {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>}
98− {row.actions.length > 0 && (
99− <div className="mt-2 flex flex-wrap gap-2">
100− {row.actions.map((action, at) => (
101− <Button key={action.id} size="xs" variant={at === 0 ? "secondary" : "outline"} onClick={() => void desktopRunner()?.act(action.id).catch(() => {})}>
102− {action.label}
103− </Button>
116+ <Actions actions={row.actions} />
117+ {row.capabilities.length > 1 && (
118+ <ul className="mt-2 space-y-1.5 border-t border-line pt-2">
119+ {row.capabilities.map((capability) => (
120+ <li key={capability.id} className="flex items-start gap-2">
121+ <span className={`mt-1.5 size-1.5 shrink-0 rounded-full ${DOT[capability.tone]}`} aria-hidden />
122+ <div className="min-w-0">
123+ <p className="text-sm text-fg">
124+ {capability.label}: {capability.title}
125+ </p>
126+ {capability.cause && <p className="text-sm text-muted">{capability.cause}</p>}
127+ {capability.fix && <p className="text-sm text-fg-soft">{capability.fix}</p>}
128+ <Actions actions={capability.actions} />
129+ </div>
130+ </li>
104131 ))}
105− </div>
132+ </ul>
106133 )}
107134 </div>
108135 <div className="shrink-0 text-right text-xs text-faint">
+28−0
4646 assert.equal(reportDue(last, busy, at + 1000), true);
4747 });
4848
49+test("a sandboxed desktop reports each kind of job, without its buttons", () => {
50+ const linux = { id: "linux", label: "Linux jobs", tone: "ok" as const, title: "Waiting for jobs", cause: null, fix: null, actions: [] };
51+ const windows = {
52+ id: "windows",
53+ label: "Windows jobs",
54+ tone: "off" as const,
55+ title: "Not available",
56+ cause: "Windows Sandbox is off, or this edition of Windows has none: Home doesn't.",
57+ fix: "Linux jobs still run.",
58+ actions: [{ id: "windows_features", label: "Open Windows features" }],
59+ };
60+ const sandboxed = view({ backend: "sandboxed", diagnosis: { code: "idle", tone: "ok", title: "Waiting for jobs", cause: null, fix: null, actions: [], capabilities: [linux, windows] } });
61+ const report = reportOf(sandboxed)!;
62+ assert.deepEqual(report.diagnosis.capabilities, [
63+ { id: "linux", label: "Linux jobs", tone: "ok", title: "Waiting for jobs", cause: null, fix: null },
64+ { id: "windows", label: "Windows jobs", tone: "off", title: "Not available", cause: windows.cause, fix: "Linux jobs still run." },
65+ ]);
66+ // One kind of job changing is a report due.
67+ const last = { key: reportKey(report), at: 0 };
68+ const running = reportOf(view({ ...sandboxed, diagnosis: { ...sandboxed.diagnosis, capabilities: [linux, { ...windows, tone: "ok", title: "Waiting for jobs", cause: null, fix: null }] } }))!;
69+ assert.equal(reportDue(last, running, 1000), true);
70+ // What the server keeps: only kinds of job that read, and none for a desktop without any.
71+ const kept = diagnosisOf({ ...report.diagnosis, capabilities: [...report.diagnosis.capabilities!, { id: "x", tone: "loud" }, "linux"] })!;
72+ assert.equal(kept.capabilities?.length, 2);
73+ assert.equal(diagnosisOf({ code: "idle", tone: "ok", title: "Waiting for jobs" })!.capabilities, undefined);
74+ assert.equal(reportOf(view())!.diagnosis.capabilities, undefined);
75+});
76+
4977 test("only a diagnosis and a backend the app makes are kept", () => {
5078 assert.deepEqual(diagnosisOf({ code: "idle", tone: "ok", title: "Waiting for jobs", cause: "Running sandboxed.", extra: 1 }), {
5179 code: "idle",
+33−5
1818 */
1919 import { useEffect, useState } from "react";
2020
21−import type { DesktopBackend, DesktopDiagnosis, DesktopReport } from "@g1t/contracts";
21+import type { DesktopBackend, DesktopCapability, DesktopDiagnosis, DesktopReport } from "@g1t/contracts";
2222
2323 /** A fix, as a button: its id is the app's (`runner_control`). */
2424 export type DesktopRunnerAction = { id: string; label: string };
2525
26+/** A kind of job the sandbox runs, with its fixes' buttons (`diagnosis::CapabilityLine` in the app). */
27+export type DesktopRunnerCapability = DesktopCapability & { actions: DesktopRunnerAction[] };
28+
2629 /** What the app says of its runner (`runner::View` in the app). */
2730 export type DesktopRunnerView = {
28− diagnosis: DesktopDiagnosis & { actions: DesktopRunnerAction[] };
31+ diagnosis: Omit<DesktopDiagnosis, "capabilities"> & { actions: DesktopRunnerAction[]; capabilities?: DesktopRunnerCapability[] };
2932 closed: DesktopDiagnosis & { actions: DesktopRunnerAction[] };
3033 running: boolean;
3134 version: string | null;
8790 /** What goes to the server, without the buttons, which only work on that computer. */
8891 export function reportOf(view: DesktopRunnerView): DesktopReport | null {
8992 if (!view.workspace) return null;
90− const plain = ({ code, tone, title, cause, fix }: DesktopDiagnosis): DesktopDiagnosis => ({ code, tone, title, cause, fix });
93+ const plain = ({ code, tone, title, cause, fix, capabilities }: DesktopDiagnosis): DesktopDiagnosis => {
94+ const said: DesktopDiagnosis = { code, tone, title, cause, fix };
95+ if (capabilities?.length) said.capabilities = capabilities.map(({ id, label, tone, title, cause, fix }) => ({ id, label, tone, title, cause: cause ?? null, fix: fix ?? null }));
96+ return said;
97+ };
9198 return { workspace: view.workspace, machine: view.machine, backend: view.backend, version: view.version, diagnosis: plain(view.diagnosis), closed: plain(view.closed) };
9299 }
93100
99106
100107 export function reportKey(report: DesktopReport): string {
101108 const { diagnosis } = report;
102− return [report.workspace, report.machine, report.backend, report.version, diagnosis.code, diagnosis.title, diagnosis.cause, diagnosis.fix].join("\u0000");
109+ const capabilities = (diagnosis.capabilities ?? []).map((c) => [c.id, c.tone, c.title, c.cause, c.fix].join("\u0001"));
110+ return [report.workspace, report.machine, report.backend, report.version, diagnosis.code, diagnosis.title, diagnosis.cause, diagnosis.fix, ...capabilities].join("\u0000");
103111 }
104112
105113 /** A string with something in it, or null. */
120128 const title = text(given.title);
121129 const tone = TONES.find((t) => t === given.tone);
122130 if (!code || !title || !tone) return null;
123− return { code, tone, title, cause: text(given.cause), fix: text(given.fix) };
131+ const said: DesktopDiagnosis = { code, tone, title, cause: text(given.cause), fix: text(given.fix) };
132+ const capabilities = Array.isArray(given.capabilities)
133+ ? given.capabilities.map(capabilityOf).filter((c): c is DesktopCapability => c !== null).slice(0, MAX_CAPABILITIES)
134+ : [];
135+ if (capabilities.length > 0) said.capabilities = capabilities;
136+ return said;
137+}
138+
139+/** The most kinds of job a desktop reports (the server keeps as many). */
140+const MAX_CAPABILITIES = 4;
141+
142+/** A kind of job as the app sent it, or null when it is not one. */
143+function capabilityOf(value: unknown): DesktopCapability | null {
144+ if (!value || typeof value !== "object") return null;
145+ const given = value as Record<string, unknown>;
146+ const id = text(given.id);
147+ const label = text(given.label);
148+ const title = text(given.title);
149+ const tone = TONES.find((t) => t === given.tone);
150+ if (!id || !label || !title || !tone) return null;
151+ return { id, label, tone, title, cause: text(given.cause), fix: text(given.fix) };
124152 }
125153
126154 /** The words for how a desktop runs jobs, as its settings say them. */
+24−1
250250 /// A diagnosis, as the app made it.
251251 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
252252 pub struct DesktopDiagnosis {
253− /// `idle`, `busy`, `no_docker`, `no_sandbox`, `not_allowed`, …
253+ /// `idle`, `busy`, `no_docker`, `no_wsl`, `sandbox_failed`, `not_allowed`, …
254254 pub code: String,
255255 /// `ok`, `working`, `off`, `warning` or `problem`.
256256 pub tone: String,
259259 pub cause: Option<String>,
260260 #[serde(default)]
261261 pub fix: Option<String>,
262+ /// A sandboxed desktop's kinds of job, each with how it is: Linux jobs
263+ /// can run while Windows jobs cannot. Empty for the other backends.
264+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
265+ pub capabilities: Vec<DesktopCapability>,
262266 }
263267
268+/// One kind of job a sandboxed desktop runs, as its app says it.
269+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
270+pub struct DesktopCapability {
271+ /// `linux`, `windows` or `macos`.
272+ pub id: String,
273+ /// `Linux jobs`.
274+ pub label: String,
275+ pub tone: String,
276+ /// `Waiting for jobs`, `Not available`.
277+ pub title: String,
278+ #[serde(default)]
279+ pub cause: Option<String>,
280+ #[serde(default)]
281+ pub fix: Option<String>,
282+}
283+
284+/// The most kinds of job a desktop reports.
285+pub const MAX_DESKTOP_CAPABILITIES: usize = 4;
286+
264287 /// One person's computer, as its desktop app last said.
265288 #[derive(Clone, Debug, Serialize, Deserialize)]
266289 #[serde(rename_all = "camelCase")]
+14−0
152152 title: string;
153153 cause: string | null;
154154 fix: string | null;
155+ /** A sandboxed desktop's kinds of job, each with how it is: Linux jobs can run while Windows jobs cannot. */
156+ capabilities?: DesktopCapability[];
157+};
158+
159+/** One kind of job a sandboxed desktop runs (`linux`, `windows`, `macos`), as its app says it. */
160+export type DesktopCapability = {
161+ id: string;
162+ /** `Linux jobs`. */
163+ label: string;
164+ tone: DesktopDiagnosis["tone"];
165+ /** `Waiting for jobs`, `Not available`. */
166+ title: string;
167+ cause: string | null;
168+ fix: string | null;
155169 };
156170
157171 /** How a desktop runs jobs. */
+14−1
1515 use g1t_contracts::billing::{ComputeKind, RecordSandboxArgs};
1616 use g1t_contracts::repos::{Repo, RepoPath};
1717 use g1t_contracts::runners::{
18− self as model, ACTIVITY_LIMIT, CloudJob, DesktopDiagnosis, DesktopRunner, DesktopRunnersArgs, ReportDesktopArgs, HandedOverTask, RunnerActivity, RunnerActivityArgs, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs,
18+ self as model, ACTIVITY_LIMIT, CloudJob, DesktopCapability, DesktopDiagnosis, DesktopRunner, DesktopRunnersArgs, ReportDesktopArgs, HandedOverTask, RunnerActivity, RunnerActivityArgs, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs,
1919 ListRunnersArgs, Poll, PollArgs, RegisterArgs, Registered, RegistrationToken, RemoveRunnerArgs, RemoveSelfArgs, RouteArgs, Runner,
2020 RunnerAuth, RunnerGroup, RunnerGroupsArgs, RunnerSettings, RunnerSettingsArgs, RunnerWork, RunnersOwner, SetRunnerGroupArgs,
2121 SetRunnerSettingsArgs, Wanted,
726726 title: clip(&d.title, 200),
727727 cause: d.cause.as_deref().map(|t| clip(t, 1000)),
728728 fix: d.fix.as_deref().map(|t| clip(t, 1000)),
729+ capabilities: d
730+ .capabilities
731+ .iter()
732+ .take(model::MAX_DESKTOP_CAPABILITIES)
733+ .map(|c| DesktopCapability {
734+ id: clip(&c.id, 20),
735+ label: clip(&c.label, 60),
736+ tone: clip(&c.tone, 20),
737+ title: clip(&c.title, 200),
738+ cause: c.cause.as_deref().map(|t| clip(t, 1000)),
739+ fix: c.fix.as_deref().map(|t| clip(t, 1000)),
740+ })
741+ .collect(),
729742 };
730743 let at = now_ms();
731744 let known = self