Skip to content

Commit

The desktop app's Runner settings show each kind of job as a card of its own, and each starts and stops on its own. Linux jobs and Windows jobs (macOS jobs once they are released) sit side by side, one under the other in a narrow window: an icon and name, a label for its state (Running a job, Waiting for jobs, Starting, Downloading image with how far, Updating, Waiting for image, Stopped, Not available, Problem), a line of what it is, the job it is running with a link to it, and when it isn't running why and the fix, with the fix's buttons and its own Stop or Start; its runner's name on g1t, version and isolation sit quietly below. Above the cards there is only what is about the whole runner (signed out, no workspace, turned off, stopped, the sandbox down), so nothing is said twice. Commands in the catalogue's sentences show as code, state changes are announced to screen readers, and the breathing dot stops under reduced motion.

Stopping Windows jobs leaves Linux jobs running, and the other way round. Which kinds are on is kept in the app's settings (runnerCapabilities, both on by default); every `up` names them, and stopping one is `down --only` it. g1t-runner's sandbox launcher gains that: `up --only linux,windows` turns those on and starts them, leaving the others as they are; `down --only windows` stops Windows jobs and keeps them off until `up --only windows`; `--no-native` and `--native` now mean the same as those for the OS's own kind. Its status says `turned_off` on each capability, whose state is then `stopped` with nothing in error, so a kind the person stopped reads as stopped, not failing, in the app and on the site; a Windows runner that didn't start is in `error` with why. The app never sends `down --only` to an older launcher, which would read it as `down`. A release published without the sandbox's images no longer reads as a failed download with "check the connection, or report it": Linux jobs say Waiting for the sandbox's image, "g1t-runner 0.3.0 is published without the sandbox's images yet, so Linux jobs can't start", and that g1t checks again every few minutes, with nothing for the person to do. The app looks again every five minutes in that state, by itself, while Windows jobs run on, and a kind that should run and doesn't while the other runs is started again by itself after a wait that grows to ten minutes; Retry and Check now look at once. The rest of the catalogue says what is actually wrong: an image that doesn't match the release's signature is told apart from one that didn't download, and the fixes for a failed sandbox, a stopped runner, a failed registration and a failed update say when g1t tries again and what the person can do now. The image's download says how far it is, by tenths, which the card shows as a bar. The Runners page's Desktops section shows a sandboxed desktop's kinds of job the same way, as cards in shadcn components, with a link to the run each is running and, for this computer inside the app, each kind's Stop or Start. Desktop reports carry each kind's state, whether it was stopped, its job, runner, isolation and version, and whether the cards say it all (`in_capabilities`), all snake_case. The desktop and self-hosted runner guides say all of it, the troubleshooting table each state's words.

syntaqxcommitted Parentdf7eb7cBrowse files
20 files+2371−3230/20 viewed
+314−24
99 --accent: #b6a8ff;
1010 --accent-ink: #15112e;
1111 --error: #ff8a8a;
12+ /* States, as dots and labels: running, on its way, needs a look, failing. */
13+ --ok: #4ade80;
14+ --working: #60a5fa;
15+ --warning: #fbbf24;
16+ --raised: #1e1e24;
17+ --line-strong: #34343c;
1218 color-scheme: dark;
1319 }
1420 * {
6975 overflow-y: auto;
7076 }
7177 main {
72− max-width: 36rem;
78+ max-width: 38rem;
7379 margin: 0 auto;
7480 padding: 1.5rem 1.25rem 1.25rem;
7581 }
261267 .diagnosis-text .buttons:empty {
262268 display: none;
263269 }
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] {
270+.diagnosis[hidden] {
275271 display: none;
276272 }
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−}
285273 .dot {
286274 flex: none;
287275 width: 8px;
291279 background: var(--muted);
292280 }
293281 .dot.ok {
294− background: #4ade80;
282+ background: var(--ok);
295283 }
296284 .dot.working {
297− background: #60a5fa;
285+ background: var(--working);
298286 }
299287 .dot.warning {
300− background: #fbbf24;
288+ background: var(--warning);
301289 }
302290 .dot.problem {
303291 background: var(--error);
304292 }
305293 .warning-note {
306− color: #fbbf24;
294+ color: var(--warning);
295+}
296+.visually-hidden {
297+ position: absolute;
298+ width: 1px;
299+ height: 1px;
300+ margin: -1px;
301+ padding: 0;
302+ overflow: hidden;
303+ clip: rect(0 0 0 0);
304+ white-space: nowrap;
305+ border: 0;
306+}
307+
308+/* Sandboxed, each kind of job it runs, side by side: Linux jobs, Windows
309+ jobs. Two to a row where they fit, one under the other where they don't. */
310+.kinds {
311+ display: grid;
312+ grid-template-columns: repeat(auto-fit, minmax(15.5rem, 1fr));
313+ gap: 0.75rem;
314+ margin: 0.5rem 0 0.75rem;
315+}
316+.kinds[hidden] {
317+ display: none;
318+}
319+.kinds.single {
320+ grid-template-columns: 1fr;
321+}
322+.kind {
323+ display: flex;
324+ flex-direction: column;
325+ gap: 0.625rem;
326+ min-width: 0;
327+ padding: 0.875rem;
328+ border: 1px solid var(--line);
329+ border-radius: 12px;
330+ background: var(--surface);
331+ transition: border-color 0.2s;
332+}
333+/* A kind that needs a look says so with its edge as well as its label. */
334+.kind[data-tone="problem"] {
335+ border-color: rgba(255, 138, 138, 0.35);
336+}
337+.kind[data-tone="warning"] {
338+ border-color: rgba(251, 191, 36, 0.3);
339+}
340+/* Its icon, and beside it its name over its state. */
341+.kind-head {
342+ display: flex;
343+ align-items: center;
344+ gap: 0.75rem;
345+}
346+.kind-icon {
347+ flex: none;
348+ display: grid;
349+ place-items: center;
350+ width: 40px;
351+ height: 40px;
352+ border-radius: 10px;
353+ background: var(--raised);
354+ color: var(--fg);
355+}
356+.kind.off .kind-icon {
357+ color: var(--muted);
358+}
359+.kind-name {
360+ display: flex;
361+ flex-direction: column;
362+ align-items: flex-start;
363+ gap: 0.25rem;
364+ min-width: 0;
365+ flex: 1 1 auto;
366+}
367+.kind-name h3 {
368+ margin: 0;
369+ font-size: 0.9375rem;
370+ font-weight: 600;
371+ letter-spacing: -0.005em;
372+}
373+.kind-about {
374+ margin: 0;
375+ color: var(--muted);
376+ font-size: 0.8125rem;
377+ line-height: 1.4;
378+}
379+/* Its state, as a label: a dot in its tone, and a few words. */
380+.pill {
381+ display: inline-flex;
382+ align-items: center;
383+ gap: 0.375rem;
384+ flex: none;
385+ padding: 0.0625rem 0.5rem 0.0625rem 0.4375rem;
386+ border: 1px solid var(--line-strong);
387+ border-radius: 999px;
388+ color: var(--muted);
389+ font-size: 0.75rem;
390+ font-weight: 500;
391+ line-height: 1.4;
392+ white-space: nowrap;
393+}
394+.pill-dot {
395+ width: 6px;
396+ height: 6px;
397+ border-radius: 50%;
398+ background: currentColor;
399+}
400+.pill[data-tone="ok"] {
401+ border-color: rgba(74, 222, 128, 0.35);
402+ background: rgba(74, 222, 128, 0.1);
403+ color: var(--ok);
404+}
405+.pill[data-tone="working"] {
406+ border-color: rgba(96, 165, 250, 0.35);
407+ background: rgba(96, 165, 250, 0.1);
408+ color: var(--working);
409+}
410+.pill[data-tone="warning"] {
411+ border-color: rgba(251, 191, 36, 0.35);
412+ background: rgba(251, 191, 36, 0.1);
413+ color: var(--warning);
414+}
415+.pill[data-tone="problem"] {
416+ border-color: rgba(255, 138, 138, 0.4);
417+ background: rgba(255, 138, 138, 0.1);
418+ color: var(--error);
419+}
420+/* On its way (running a job, starting, downloading): the dot breathes. */
421+.pill.live .pill-dot {
422+ animation: breathe 1.6s ease-in-out infinite;
423+}
424+@keyframes breathe {
425+ 50% {
426+ opacity: 0.35;
427+ }
428+}
429+.kind-body {
430+ display: flex;
431+ flex-direction: column;
432+ gap: 0.25rem;
433+ font-size: 0.8125rem;
434+ line-height: 1.45;
435+}
436+.kind-body[hidden] {
437+ display: none;
438+}
439+.kind-body p {
440+ margin: 0;
441+}
442+.kind-headline {
443+ color: var(--fg);
444+ font-size: 0.875rem;
445+ font-weight: 500;
446+}
447+.kind-cause {
448+ color: var(--muted);
449+}
450+.kind-fix {
451+ color: var(--fg);
452+}
453+.kind-body code,
454+.diagnosis-text code {
455+ padding: 0.0625rem 0.3125rem;
456+ border-radius: 5px;
457+ background: var(--raised);
458+ color: var(--fg);
459+ font-size: 0.75rem;
460+ overflow-wrap: anywhere;
461+ user-select: text;
462+}
463+.kind-job {
464+ display: flex;
465+ flex-direction: column;
466+ gap: 0.125rem;
467+ padding: 0.5rem 0.625rem;
468+ border-radius: 8px;
469+ background: var(--raised);
470+}
471+.kind-job-label {
472+ color: var(--muted);
473+ font-size: 0.6875rem;
474+ font-weight: 600;
475+ letter-spacing: 0.05em;
476+ text-transform: uppercase;
477+}
478+.kind-job-what {
479+ min-width: 0;
480+ overflow-wrap: anywhere;
481+}
482+.kind-job-what a {
483+ color: var(--fg);
484+ font-weight: 500;
485+}
486+.kind-job-what a:hover {
487+ color: var(--accent);
488+}
489+.kind-progress {
490+ display: flex;
491+ align-items: center;
492+ gap: 0.625rem;
493+}
494+.kind-progress .bar {
495+ flex: 1;
496+ height: 6px;
497+ overflow: hidden;
498+ border-radius: 999px;
499+ background: var(--line);
500+}
501+.kind-progress .bar span {
502+ display: block;
503+ height: 100%;
504+ border-radius: 999px;
505+ background: var(--accent);
506+ transition: width 0.4s ease;
507+}
508+.kind-progress .small {
509+ min-width: 2.5rem;
510+ text-align: right;
511+ font-variant-numeric: tabular-nums;
512+}
513+/* What it is on g1t: there to read, never in the way. */
514+.kind-details {
515+ display: grid;
516+ gap: 0.125rem;
517+ margin: 0;
518+ padding-top: 0.5rem;
519+ border-top: 1px solid var(--line);
520+ font-size: 0.75rem;
521+}
522+.kind-details[hidden] {
523+ display: none;
524+}
525+.kind-details div {
526+ display: flex;
527+ align-items: baseline;
528+ gap: 0.5rem;
529+ min-width: 0;
530+}
531+.kind-details dt {
532+ flex: none;
533+ width: 4.25rem;
534+ color: var(--muted);
535+}
536+.kind-details dd {
537+ display: flex;
538+ align-items: baseline;
539+ flex-wrap: wrap;
540+ gap: 0 0.5rem;
541+ min-width: 0;
542+ margin: 0;
543+ color: var(--fg);
544+ overflow-wrap: anywhere;
545+ user-select: text;
546+}
547+button.link {
548+ display: inline-flex;
549+ align-items: center;
550+ gap: 0.25rem;
551+ padding: 0;
552+ border: 0;
553+ background: none;
554+ color: var(--fg);
555+ font-size: inherit;
556+ font-weight: 400;
557+ text-decoration: underline;
558+ text-decoration-color: var(--line-strong);
559+ text-underline-offset: 3px;
560+}
561+button.link:hover {
562+ color: var(--accent);
563+ text-decoration-color: currentColor;
564+}
565+button.link svg {
566+ flex: none;
567+ color: var(--muted);
568+}
569+.kind-actions {
570+ display: flex;
571+ flex-wrap: wrap;
572+ align-items: center;
573+ gap: 0.5rem;
574+ margin-top: auto;
575+}
576+.kind-actions[hidden] {
577+ display: none;
578+}
579+.kind-actions .end {
580+ margin-left: auto;
581+}
582+button.quiet {
583+ background: transparent;
584+ color: var(--muted);
585+}
586+button.quiet:hover {
587+ color: var(--fg);
588+}
589+@media (prefers-reduced-motion: reduce) {
590+ .pill.live .pill-dot {
591+ animation: none;
592+ }
593+ .kind,
594+ .kind-progress .bar span {
595+ transition: none;
596+ }
307597 }
308598 details.log {
309599 padding: 0.75rem 0;
+8−5
5353 >
5454 <input type="checkbox" role="switch" id="runner" />
5555 </label>
56− <!-- What it is doing; when it isn't running, why and how to fix it (src/runner/diagnosis.rs). -->
57− <div class="diagnosis" id="runner-diagnosis" aria-live="polite">
56+ <!--
57+ What affects the whole runner (signed out, no workspace, turned off,
58+ the sandbox down): why, and how to fix it (src/runner/diagnosis.rs).
59+ Hidden when the cards below say it all.
60+ -->
61+ <div class="diagnosis" id="runner-diagnosis" role="status" aria-live="polite" hidden>
5862 <span class="dot" id="runner-dot" aria-hidden="true"></span>
5963 <span class="diagnosis-text">
6064 <strong id="runner-title">Runner</strong>
6165 <span class="muted" id="runner-cause"></span>
6266 <span id="runner-fix"></span>
6367 <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>
6668 </span>
67− <button type="button" id="runner-toggle">Stop</button>
6869 </div>
70+ <!-- Sandboxed: each kind of job it runs, a card of its own, started and stopped on its own. -->
71+ <div class="kinds" id="runner-kinds" role="list" aria-label="Kinds of job this computer runs" hidden></div>
6972 <label class="setting">
7073 <span><strong>How jobs run</strong><span class="muted" id="runner-backend-note">&nbsp;</span></span>
7174 <select id="runnerBackend">
+262−34
8080 settings.zoom = Number(byId("zoom").value) || 1;
8181 settings.runnerBackend = byId("runnerBackend").value || "sandboxed";
8282 settings.runnerWorkspace = byId("runnerWorkspace").value || null;
83+ // Which kinds of job are on is changed by their cards' Start and Stop, and kept as it is here.
84+ if (current && current.runnerCapabilities) settings.runnerCapabilities = current.runnerCapabilities;
8385 return settings;
8486 }
8587
113115 return button;
114116 }
115117
116− function span(className, text) {
117− var item = document.createElement("span");
118+ function element(tag, className, text) {
119+ var item = document.createElement(tag);
118120 if (className) item.className = className;
119− item.textContent = text;
121+ if (text != null) item.textContent = text;
120122 return item;
121123 }
122124
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));
125+ // The catalogue's sentences name commands in backticks: `wsl --install`.
126+ // Said here as code, which can be selected and copied.
127+ function prose(into, text) {
128+ into.textContent = "";
129+ String(text || "")
130+ .split("`")
131+ .forEach(function (part, at) {
132+ if (!part) return;
133+ into.appendChild(at % 2 ? element("code", null, part) : document.createTextNode(part));
134+ });
135+ return into;
136+ }
137+
138+ // ── Each kind of job, a card of its own ───────────────────────────────
139+ // Linux jobs and Windows jobs (macOS jobs once they are released): what
140+ // each is doing, why one isn't running and its fix, and its own Start or
141+ // Stop, which leaves the other as it is. The words are the app's
142+ // (CapabilityLine in src/runner/diagnosis.rs); the card only lays them out.
143+ var SVG = "http://www.w3.org/2000/svg";
144+ // Plain line icons, 16px on a 24px grid: a terminal for Linux jobs, a
145+ // window for Windows jobs, a laptop for macOS jobs.
146+ var ICONS = {
147+ linux: ["M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z", "M7 10l3 2.5L7 15", "M12.5 15h4"],
148+ windows: ["M4 4h16a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z", "M3 9h18", "M6.5 6.5h.01", "M9 6.5h.01"],
149+ macos: ["M5 6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v9H5z", "M2.5 18.5h19", "M3.5 18.5L5 15h14l1.5 3.5"],
150+ };
151+ // How each runner keeps a job from this computer, said plainly.
152+ var ISOLATION = { "nested-container": "A fresh container in the VM", vm: "A VM of its own", container: "A container of its own", host: "None" };
153+ var BUSY = { busy: true, starting: true, downloading: true, updating: true };
154+ var cards = {};
155+ var pending = {};
156+
157+ // A small arrow: the link goes out to g1t.
158+ function outward() {
159+ var svg = document.createElementNS(SVG, "svg");
160+ svg.setAttribute("viewBox", "0 0 24 24");
161+ svg.setAttribute("width", "11");
162+ svg.setAttribute("height", "11");
163+ svg.setAttribute("fill", "none");
164+ svg.setAttribute("stroke", "currentColor");
165+ svg.setAttribute("stroke-width", "2");
166+ svg.setAttribute("stroke-linecap", "round");
167+ svg.setAttribute("aria-hidden", "true");
168+ var path = document.createElementNS(SVG, "path");
169+ path.setAttribute("d", "M7 17L17 7M9 7h8v8");
170+ svg.appendChild(path);
171+ return svg;
172+ }
173+
174+ function icon(id) {
175+ var svg = document.createElementNS(SVG, "svg");
176+ svg.setAttribute("viewBox", "0 0 24 24");
177+ svg.setAttribute("width", "16");
178+ svg.setAttribute("height", "16");
179+ svg.setAttribute("fill", "none");
180+ svg.setAttribute("stroke", "currentColor");
181+ svg.setAttribute("stroke-width", "1.75");
182+ svg.setAttribute("stroke-linecap", "round");
183+ svg.setAttribute("stroke-linejoin", "round");
184+ svg.setAttribute("aria-hidden", "true");
185+ (ICONS[id] || ICONS.linux).forEach(function (d) {
186+ var path = document.createElementNS(SVG, "path");
187+ path.setAttribute("d", d);
188+ svg.appendChild(path);
189+ });
190+ return svg;
191+ }
192+
193+ // A card's parts, made once and filled in on every status.
194+ function makeCard(kind) {
195+ var card = element("article", "kind");
196+ card.setAttribute("role", "listitem");
197+ card.setAttribute("aria-labelledby", "kind-" + kind.id + "-title");
198+ var head = element("header", "kind-head");
199+ var badge = element("span", "kind-icon");
200+ badge.appendChild(icon(kind.id));
201+ var name = element("span", "kind-name");
202+ var title = element("h3", null, kind.label);
203+ title.id = "kind-" + kind.id + "-title";
204+ name.appendChild(title);
205+ var pill = element("span", "pill");
206+ // Its state is said when it changes, as "Linux jobs: Waiting for jobs".
207+ pill.setAttribute("role", "status");
208+ pill.setAttribute("aria-live", "polite");
209+ pill.setAttribute("aria-atomic", "true");
210+ var said = element("span", "visually-hidden", kind.label + ": ");
211+ pill.appendChild(said);
212+ pill.appendChild(element("span", "pill-dot"));
213+ pill.appendChild(element("span", "pill-text"));
214+ name.appendChild(pill);
215+ head.appendChild(badge);
216+ head.appendChild(name);
217+ card.appendChild(head);
218+ card.appendChild(element("p", "kind-about"));
219+ var body = element("div", "kind-body");
220+ card.appendChild(body);
221+ var details = element("dl", "kind-details");
222+ card.appendChild(details);
223+ var actions = element("div", "kind-actions");
224+ card.appendChild(actions);
225+ return { root: card, pill: pill, about: card.querySelector(".kind-about"), body: body, details: details, actions: actions, signature: "" };
226+ }
227+
228+ // What it is running, with where to see it on g1t.
229+ function jobRow(job) {
230+ var row = element("div", "kind-job");
231+ row.appendChild(element("span", "kind-job-label", job.kind === "agent" ? "Agent work" : "Running"));
232+ var what = element("span", "kind-job-what");
233+ if (job.link) {
234+ var link = element("a", null, job.name);
235+ link.href = job.link;
236+ what.appendChild(link);
237+ } else {
238+ what.appendChild(element("strong", null, job.name));
239+ }
240+ if (job.repo) what.appendChild(element("span", "muted", " in " + job.repo));
241+ row.appendChild(what);
242+ return row;
243+ }
244+
245+ function progressRow(kind) {
246+ var row = element("div", "kind-progress");
247+ var bar = element("div", "bar");
248+ bar.setAttribute("role", "progressbar");
249+ bar.setAttribute("aria-valuemin", "0");
250+ bar.setAttribute("aria-valuemax", "100");
251+ bar.setAttribute("aria-valuenow", String(kind.progress));
252+ bar.setAttribute("aria-label", kind.label + ": the sandbox's image");
253+ var fill = element("span");
254+ fill.style.width = Math.max(2, Math.min(100, kind.progress)) + "%";
255+ bar.appendChild(fill);
256+ row.appendChild(bar);
257+ row.appendChild(element("span", "muted small", kind.progress + "%"));
258+ return row;
259+ }
260+
261+ function detail(list, term, value, given) {
262+ if (!value) return;
263+ var row = element("div");
264+ row.appendChild(element("dt", null, term));
265+ var dd = element("dd", null, given ? null : value);
266+ if (given) dd.appendChild(given);
267+ row.appendChild(dd);
268+ list.appendChild(row);
269+ }
270+
271+ // Start or Stop for one kind of job, said for which, and what it is doing meanwhile.
272+ function toggleButton(kind, onFirst) {
273+ var on = kind.enabled;
274+ var waiting = pending[kind.id];
275+ var button = element("button", on ? "quiet" : onFirst ? "primary" : null);
276+ button.type = "button";
277+ button.textContent = waiting ? (on ? "Stopping…" : "Starting…") : on ? "Stop" : "Start";
278+ button.setAttribute("aria-label", (on ? "Stop " : "Start ") + kind.label);
279+ button.disabled = !!waiting;
280+ button.addEventListener("click", function () {
281+ pending[kind.id] = true;
282+ renderKinds(lastKinds);
283+ invoke("runner_control", { action: on ? "stop" : "start", capability: kind.id })
284+ .catch(fail)
285+ .then(function () {
286+ // The status that follows says how it is; until then it says what was asked.
287+ setTimeout(function () {
288+ delete pending[kind.id];
289+ renderKinds(lastKinds);
290+ }, 1500);
291+ });
292+ });
293+ return button;
294+ }
295+
296+ function fillCard(card, kind, runner) {
297+ var root = card.root;
298+ root.dataset.state = kind.state;
299+ root.dataset.tone = kind.tone;
300+ root.classList.toggle("off", !kind.enabled);
301+ card.pill.dataset.tone = kind.tone;
302+ card.pill.classList.toggle("live", !!BUSY[kind.state]);
303+ // Said again only when it changes, so a screen reader hears each change once.
304+ var text = card.pill.querySelector(".pill-text");
305+ if (text.textContent !== kind.title) text.textContent = kind.title;
306+ if (card.about.textContent !== kind.about) card.about.textContent = kind.about;
307+
308+ // What changes the buttons and the words is drawn again only when it changes,
309+ // so a button keeps its focus while the status ticks.
310+ var signature = JSON.stringify([kind, !!pending[kind.id], runner.link]);
311+ if (signature === card.signature) return;
312+ card.signature = signature;
313+
314+ var body = card.body;
315+ body.textContent = "";
316+ if (kind.job) body.appendChild(jobRow(kind.job));
317+ if (kind.state === "downloading" && kind.progress != null) body.appendChild(progressRow(kind));
318+ if (kind.headline) body.appendChild(element("p", "kind-headline", kind.headline));
319+ if (kind.cause) body.appendChild(prose(element("p", "kind-cause"), kind.cause));
320+ if (kind.fix) body.appendChild(prose(element("p", "kind-fix"), kind.fix));
321+ body.hidden = !body.firstChild;
322+
323+ var details = card.details;
324+ details.textContent = "";
325+ // Its name on g1t opens the workspace's Runners page, where it is listed.
326+ var named = null;
327+ if (kind.runner && runner.link) {
328+ named = element("button", "link", kind.runner);
329+ named.type = "button";
330+ named.setAttribute("aria-label", "View " + kind.runner + " on g1t");
331+ named.appendChild(outward());
332+ named.addEventListener("click", function () {
333+ invoke("runner_control", { action: "view_runners" }).catch(fail);
142334 });
143− text.appendChild(buttons);
144− item.appendChild(text);
145− list.appendChild(item);
335+ }
336+ detail(details, "On g1t", kind.runner, named);
337+ detail(details, "Version", kind.version ? "g1t-runner " + kind.version : null);
338+ detail(details, "Isolation", ISOLATION[kind.isolation] || kind.isolation);
339+ details.hidden = !details.firstChild;
340+
341+ var actions = card.actions;
342+ actions.textContent = "";
343+ // Its first fix stands out when it is the person's to do; while g1t
344+ // sees to it (waiting for the release's images), none does.
345+ var urgent = kind.tone === "problem" || kind.tone === "warning";
346+ kind.actions.forEach(function (action, at) {
347+ actions.appendChild(actionButton(action, at === 0 && urgent && kind.state !== "waiting"));
348+ });
349+ if (kind.can_toggle) {
350+ var toggle = toggleButton(kind, kind.actions.length === 0);
351+ toggle.classList.add("end");
352+ actions.appendChild(toggle);
353+ }
354+ actions.hidden = !actions.firstChild;
355+ }
356+
357+ var lastKinds = null;
358+
359+ function renderKinds(runner) {
360+ lastKinds = runner;
361+ var list = byId("runner-kinds");
362+ var kinds = (runner && runner.diagnosis.capabilities) || [];
363+ list.hidden = kinds.length === 0;
364+ var seen = {};
365+ kinds.forEach(function (kind, at) {
366+ seen[kind.id] = true;
367+ var card = cards[kind.id] || (cards[kind.id] = makeCard(kind));
368+ if (list.children[at] !== card.root) list.insertBefore(card.root, list.children[at] || null);
369+ fillCard(card, kind, runner);
146370 });
371+ Object.keys(cards).forEach(function (id) {
372+ if (seen[id]) return;
373+ cards[id].root.remove();
374+ delete cards[id];
375+ });
376+ list.classList.toggle("single", kinds.length === 1);
147377 }
148378
149379 function renderRunner(runner) {
150380 if (!runner) return;
151381 var diagnosis = runner.diagnosis;
382+ // The whole runner's line, when the cards don't already say it.
383+ var cardsSayIt = diagnosis.in_capabilities && diagnosis.capabilities && diagnosis.capabilities.length > 0;
384+ byId("runner-diagnosis").hidden = !!cardsSayIt;
152385 byId("runner-dot").className = "dot " + diagnosis.tone;
153386 byId("runner-title").textContent = diagnosis.title;
154− byId("runner-cause").textContent = diagnosis.cause || "";
155− byId("runner-fix").textContent = diagnosis.fix || "";
387+ prose(byId("runner-cause"), diagnosis.cause);
388+ prose(byId("runner-fix"), diagnosis.fix);
156389 var actions = byId("runner-actions");
157390 actions.textContent = "";
158391 diagnosis.actions.forEach(function (action, at) {
159392 actions.appendChild(actionButton(action, at === 0));
160393 });
161− renderCapabilities(diagnosis.capabilities);
394+ renderKinds(runner);
162395 byId("runner").checked = !!runner.enabled;
163− var on = runner.enabled && !runner.paused;
164− byId("runner-toggle").textContent = on ? "Stop" : "Start";
165− byId("runner-toggle").dataset.action = on ? "stop" : "start";
166396
167397 var backend = byId("runnerBackend");
168398 backend.value = runner.backend;
192422
193423 byId("runnerBackend").addEventListener("change", save);
194424 byId("runnerWorkspace").addEventListener("change", save);
195− byId("runner-toggle").addEventListener("click", function (event) {
196− invoke("runner_control", { action: event.target.dataset.action || "start" }).catch(fail);
197− });
198425 byId("runner-view").addEventListener("click", function () {
199426 invoke("runner_control", { action: "view_runners" }).catch(fail);
200427 });
288515 events
289516 .listen("g1t-runner-status", function (event) {
290517 if (current) current.runnerView = event.payload;
518+ pending = {};
291519 renderRunner(event.payload);
292520 })
293521 .catch(function () {});
+6−3
153153 error: answer.error == null ? null : String(answer.error),
154154 });
155155 },
156− // Start, stop, or a diagnosis's fix, by its id.
157− act: function (action) {
158− return invoke("runner_control", { action: String(action) });
156+ // Start, stop, or a diagnosis's fix, by its id; Start and Stop of one
157+ // kind of job alone (`linux`, `windows`) with its id as `capability`.
158+ act: function (action, capability) {
159+ var args = { action: String(action) };
160+ if (capability != null) args.capability = String(capability);
161+ return invoke("runner_control", args);
159162 },
160163 // The app wants a registration token for a workspace.
161164 onWanted: function (listener) {
+4−3
289289 }
290290
291291 /// A button for the runner: Start, Stop, or a diagnosis's fix
292−/// (runner/diagnosis.rs), from settings or the site's card.
292+/// (runner/diagnosis.rs), from settings or the site's card. With a kind
293+/// of job (`capability`: `linux`, `windows`), Start and Stop are its alone.
293294 #[tauri::command]
294−pub fn runner_control(app: AppHandle, webview: Webview, action: String) -> Result<(), String> {
295+pub fn runner_control(app: AppHandle, webview: Webview, action: String, capability: Option<String>) -> Result<(), String> {
295296 from_site(&app, &webview).or_else(|_| from_settings(&webview))?;
296− runner::act(&app, &action)
297+ runner::act(&app, &action, capability.as_deref())
297298 }
298299
299300 #[cfg(test)]
+478−75
1111 //!
1212 //! The sandbox (launcher.rs) says why something is wrong in its own
1313 //! 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.
14+//! off, Windows Sandbox off, a release without the sandbox's images, …)
15+//! are said in its words, with a fix and its buttons; any other is shown as
16+//! the launcher wrote it. With the sandbox, each kind of job it runs (Linux
17+//! jobs, Windows jobs) is a card of its own (`capabilities`), with its own
18+//! state, cause, fix and Start or Stop, since one can run while the other
19+//! cannot, or is stopped. The whole runner's diagnosis then only says what
20+//! the cards don't (`in_capabilities` is false): signed out, no workspace,
21+//! not allowed, no launcher, the whole sandbox down.
1822
1923 use serde::Serialize;
2024
2933 Off,
3034 /// The person pressed Stop.
3135 Stopped,
36+ /// Sandboxed, the person stopped every kind of job there is here.
37+ KindsStopped,
3238 Starting,
3339 /// Waiting for the site to make a registration token.
3440 Registering,
5460 NoVfkit,
5561 /// Linux, with neither Sysbox in Docker nor KVM.
5662 NoSysbox,
63+ /// The newest release is published without the sandbox's images yet.
64+ WaitingForImage,
5765 /// The sandbox's image couldn't be downloaded, or isn't the release's.
5866 ImageFailed,
5967 /// The sandbox can't be made here, for a reason in the launcher's words.
97105 /// What to do about it.
98106 pub fix: Option<String>,
99107 pub actions: Vec<Action>,
100− /// With the sandbox: each kind of job it runs, on its own line.
108+ /// With the sandbox: each kind of job it runs, as a card of its own.
101109 #[serde(skip_serializing_if = "Vec::is_empty")]
102110 pub capabilities: Vec<CapabilityLine>,
111+ /// The cards say all of this, each for its kind of job: a page that
112+ /// shows them shows no line of the whole runner's as well.
113+ #[serde(skip_serializing_if = "is_false")]
114+ pub in_capabilities: bool,
115+}
116+
117+fn is_false(value: &bool) -> bool {
118+ !*value
119+}
120+
121+/// How one kind of job is, as its card's label says it.
122+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
123+#[serde(rename_all = "snake_case")]
124+pub enum KindState {
125+ /// Running a job.
126+ Busy,
127+ /// Waiting for jobs.
128+ Idle,
129+ Starting,
130+ /// Downloading the sandbox's image, which `progress` says how far.
131+ Downloading,
132+ Updating,
133+ /// Waiting for something g1t sees to by itself, such as the release's images.
134+ Waiting,
135+ /// Stopped: by the person (`enabled` is false), or with the whole runner.
136+ Stopped,
137+ /// This computer can't run it, and `cause` says why.
138+ Unavailable,
139+ /// It should run and doesn't: `cause` and `fix`.
140+ Problem,
103141 }
104142
105143 /// One kind of job the sandbox runs, `linux` or `windows` (or `macos`),
110148 /// `Linux jobs`.
111149 pub label: String,
112150 pub tone: Tone,
113− /// `Waiting for jobs`, `Not available`.
151+ pub state: KindState,
152+ /// Its state in a few words, for its label: `Waiting for jobs`, `Not available`.
114153 pub title: String,
154+ /// What it is, in a line: `A Linux VM of its own, each job in a fresh container`.
155+ pub about: String,
156+ /// What is wrong, in a few words, over its cause: `WSL isn't installed`.
157+ pub headline: Option<String>,
115158 pub cause: Option<String>,
116159 pub fix: Option<String>,
117160 pub actions: Vec<Action>,
161+ /// On in settings: the person hasn't stopped it.
162+ pub enabled: bool,
163+ /// This computer can run it.
164+ pub available: bool,
165+ /// It can be started and stopped on its own here (the launcher can).
166+ pub can_toggle: bool,
167+ /// What it is running now.
168+ pub job: Option<JobLine>,
169+ /// How far its image has downloaded, in percent.
170+ pub progress: Option<u8>,
171+ /// How it keeps jobs from the computer: `nested-container`, `vm`.
172+ pub isolation: Option<String>,
173+ /// Its runner's name on g1t: `build-box-linux`.
174+ pub runner: Option<String>,
175+ /// Its runner's version.
176+ pub version: Option<String>,
177+}
178+
179+/// The job a kind of job is running, with where to see it on g1t.
180+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
181+pub struct JobLine {
182+ /// `workflow` or `agent`.
183+ pub kind: String,
184+ pub name: String,
185+ pub repo: String,
186+ /// The repository's Actions (or Agents) page, where its run is.
187+ pub link: Option<String>,
118188 }
119189
120190 /// What the words are filled in with.
130200 pub wait_seconds: Option<u64>,
131201 /// The server's host: `g1t.sh`.
132202 pub server: String,
203+ /// The server's address, for links: `https://g1t.sh`.
204+ pub site: String,
133205 pub platform: String,
134206 /// What the sandbox says to do, for a cause in its own words.
135207 pub fix: Option<String>,
136208 /// How the sandbox is, when jobs run sandboxed.
137209 pub sandbox: Option<Status>,
210+ /// The kinds of job the person stopped in settings.
211+ pub off: Vec<String>,
212+ /// The sandbox is up, or on its way: a kind of job that should run
213+ /// and doesn't is failing, rather than stopped with everything else.
214+ pub live: bool,
215+ /// The kinds of job a launcher command is starting now.
216+ pub starting: Vec<String>,
217+ /// How far the sandbox's image has downloaded, while it does.
218+ pub download: Option<launcher::Download>,
219+ /// The person stopped the whole runner, or turned it off.
220+ pub held: bool,
138221 }
139222
140223 const START: Action = Action { id: "start", label: "Start" };
141224 const TURN_ON: Action = Action { id: "start", label: "Turn on" };
142225 const RETRY: Action = Action { id: "retry", label: "Retry" };
226+const CHECK_NOW: Action = Action { id: "retry", label: "Check now" };
143227 const RESTART: Action = Action { id: "restart", label: "Restart" };
144228 const SIGN_IN: Action = Action { id: "sign_in", label: "Sign in" };
145229 const CHOOSE_WORKSPACE: Action = Action { id: "choose_workspace", label: "Choose workspace" };
160244 Some(text.into())
161245 }
162246
247+/// A wait, said: `30 seconds`, `5 minutes`.
248+fn said_wait(seconds: u64) -> String {
249+ match seconds {
250+ 0..=1 => "a moment".into(),
251+ 2..=89 => format!("{seconds} seconds"),
252+ _ => format!("{} minutes", seconds.div_ceil(60)),
253+ }
254+}
255+
163256 /// The catalogue.
164257 pub fn diagnose(code: Code, facts: &Facts) -> Diagnosis {
165258 let workspace = facts.workspace.as_deref().unwrap_or("your workspace");
179272 None,
180273 vec![START],
181274 ),
275+ Code::KindsStopped => (
276+ Tone::Off,
277+ "Not running: every kind of job is stopped".into(),
278+ some("You stopped each kind of job this computer can run."),
279+ some("Start one to run jobs here again."),
280+ vec![],
281+ ),
182282 Code::Starting => (Tone::Working, "Starting".into(), detail, None, vec![]),
183283 Code::Registering => (
184284 Tone::Working,
212312 Tone::Problem,
213313 format!("Couldn't register with {workspace}"),
214314 detail.or_else(|| some("g1t didn't make a registration for this computer.")),
215− some("It tries again by itself in a few minutes."),
315+ some("g1t asks again by itself in a few minutes; Retry asks now. Its log says more."),
216316 vec![RETRY, VIEW_LOG],
217317 ),
218318 Code::Idle => (
246346 Tone::Warning,
247347 "Not running: this g1t-runner has no sandbox".into(),
248348 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."),
349+ some("g1t looks for a newer one by itself every few minutes; Retry looks now. Or choose to run jobs in Docker or directly on this computer: neither is a sandbox, and settings say what each can reach."),
250350 vec![RETRY, CHOOSE_BACKEND],
251351 ),
252352 Code::NoWsl => (
288388 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."),
289389 vec![INSTALL_SYSBOX, CHOOSE_BACKEND],
290390 ),
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− ),
391+ Code::WaitingForImage => {
392+ let (release, image) = detail.as_deref().and_then(launcher::missing_images).map_or((None, None), |(release, image)| (Some(release), image));
393+ let release = release.map_or_else(|| "The newest g1t-runner".to_owned(), |release| format!("g1t-runner {release}"));
394+ (
395+ Tone::Warning,
396+ "Waiting for the sandbox's image".into(),
397+ some(match image {
398+ Some(image) => format!("{release} is published without the sandbox's image for this kind of computer ({image}) yet, so Linux jobs can't start."),
399+ None => format!("{release} is published without the sandbox's images yet, so Linux jobs can't start."),
400+ }),
401+ some("g1t checks again every few minutes and starts Linux jobs once they're out. There's nothing for you to do."),
402+ vec![CHECK_NOW],
403+ )
404+ }
405+ Code::ImageFailed => {
406+ let said = detail.clone().unwrap_or_default().to_ascii_lowercase();
407+ if said.contains("sha-256") || said.contains("signature") {
408+ (
409+ Tone::Problem,
410+ "Not running: the sandbox's image didn't check out".into(),
411+ some("The sandbox's image that downloaded isn't the one g1t's release signed, so it wasn't used."),
412+ some(format!(
413+ "g1t downloads it again by itself{}. If this keeps happening, something between this computer and {} is changing downloads, such as a proxy or antivirus: report it.",
414+ facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(),
415+ facts.server
416+ )),
417+ vec![RETRY, REPORT],
418+ )
419+ } else {
420+ (
421+ Tone::Problem,
422+ "Not running: the sandbox's image didn't download".into(),
423+ some(match &detail {
424+ Some(detail) => format!("The sandbox's image couldn't be downloaded: {detail}"),
425+ None => "The sandbox's image couldn't be downloaded.".into(),
426+ }),
427+ some(format!(
428+ "g1t tries again by itself{}. Check that this computer can reach {}; Retry tries now.",
429+ facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(),
430+ facts.server
431+ )),
432+ vec![RETRY, VIEW_LOG],
433+ )
434+ }
435+ }
298436 Code::SandboxUnavailable => (
299437 Tone::Problem,
300438 "Not running: the sandbox can't run here".into(),
306444 Tone::Problem,
307445 "Not running: the sandbox failed".into(),
308446 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(),
447+ some(match facts.wait_seconds {
448+ Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Retry starts it now.", said_wait(wait)),
449+ None => "Retry starts it again; its log says why it stopped.".into(),
312450 }),
313451 vec![RETRY, VIEW_LOG],
314452 ),
324462 Tone::Warning,
325463 "Update failed".into(),
326464 detail,
327− some("The runner keeps running the version it has. Try again, or report it if it keeps happening."),
465+ some("The runner keeps running the version it has, and g1t tries the update again in a few hours. Retry tries now; if it keeps failing, report it."),
328466 vec![RETRY, REPORT],
329467 ),
330468 Code::Offline => (
342480 },
343481 detail,
344482 Some(match facts.wait_seconds {
345− Some(wait) => format!("It starts again by itself in {wait} seconds. Restart now, or look at its log."),
346− None => "Restart it, or look at its log.".into(),
483+ Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Restart starts it now.", said_wait(wait)),
484+ None => "Restart starts it again; its log says why it stopped.".into(),
347485 }),
348486 vec![RESTART, VIEW_LOG],
349487 ),
350488 };
351489 let capabilities = facts.sandbox.as_ref().map(|status| capabilities(status, facts)).unwrap_or_default();
352− Diagnosis { code, tone, title, cause, fix, actions, capabilities }
490+ let in_capabilities = !capabilities.is_empty()
491+ && match code {
492+ Code::Idle | Code::Busy | Code::KindsStopped => true,
493+ Code::Starting | Code::Updating => facts.live,
494+ // Said by a card, for its kind of job, word for word.
495+ Code::NoWsl
496+ | Code::WslMirrored
497+ | Code::NoVirtualization
498+ | Code::NoVfkit
499+ | Code::NoSysbox
500+ | Code::WaitingForImage
501+ | Code::ImageFailed
502+ | Code::SandboxUnavailable
503+ | Code::SandboxFailed => cause.is_some() && capabilities.iter().any(|c| c.cause == cause),
504+ _ => false,
505+ };
506+ Diagnosis { code, tone, title, cause, fix, actions, capabilities, in_capabilities }
353507 }
354508
355509 /// What the app calls a sandbox's problem with Linux jobs, from the
361515 Why::NoVirtualization => Code::NoVirtualization,
362516 Why::NoVfkit => Code::NoVfkit,
363517 Why::NoSysboxOrKvm => Code::NoSysbox,
518+ Why::NoImages => Code::WaitingForImage,
364519 Why::Image => Code::ImageFailed,
365520 _ => Code::SandboxFailed,
366521 }
377532 Some((code, cause, fix))
378533 }
379534
380−/// Each kind of job the sandbox runs, on a line of its own.
535+/// Each kind of job the sandbox runs, as a card of its own.
381536 fn capabilities(status: &Status, facts: &Facts) -> Vec<CapabilityLine> {
382537 let listed = if status.capabilities.is_empty() { vec![status.linux()] } else { status.capabilities.clone() };
383538 listed.iter().map(|capability| capability_line(status, capability, facts)).collect()
384539 }
385540
386−fn capability_line(status: &Status, capability: &launcher::Capability, facts: &Facts) -> CapabilityLine {
387− let label = match capability.id.as_str() {
541+/// `Linux jobs`.
542+pub fn kind_label(id: &str) -> String {
543+ match id {
388544 "linux" => "Linux jobs".to_owned(),
389545 "windows" => "Windows jobs".to_owned(),
390546 "macos" => "macOS jobs".to_owned(),
391547 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(),
548+ }
549+}
550+
551+/// What a kind of job is, in a line, by how the sandbox makes it.
552+fn about(id: &str, backend: Option<&str>) -> String {
553+ match (id, backend) {
554+ ("linux", Some("sysbox")) => "The runner under Sysbox, each job in a fresh container".into(),
555+ ("linux", _) => "A Linux VM of its own, each job in a fresh container".into(),
556+ ("windows", _) => "Each job in a Windows Sandbox, discarded after".into(),
557+ ("macos", _) => "Each job in a macOS VM of its own, discarded after".into(),
558+ _ => "Each job sandboxed".into(),
559+ }
560+}
561+
562+/// A catalogue title as a card's headline: `Not running: virtualization is off` is `Virtualization is off`.
563+fn headline(title: &str) -> String {
564+ let said = title.strip_prefix("Not running: ").unwrap_or(title);
565+ let mut letters = said.chars();
566+ letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default()
567+}
568+
569+fn capability_line(status: &Status, capability: &launcher::Capability, facts: &Facts) -> CapabilityLine {
570+ let id = capability.id.as_str();
571+ let label = kind_label(id);
572+ let enabled = !facts.off.iter().any(|off| off == id);
573+ let per_kind = status.per_kind();
574+ let mut line = CapabilityLine {
575+ id: id.to_owned(),
395576 label: label.clone(),
396− tone,
397− title,
398− cause,
399− fix,
400− actions,
577+ tone: Tone::Off,
578+ state: KindState::Stopped,
579+ title: "Stopped".into(),
580+ about: about(id, status.backend.as_deref()),
581+ headline: None,
582+ cause: None,
583+ fix: None,
584+ actions: vec![],
585+ enabled,
586+ available: capability.available,
587+ // Nothing to start or stop where it can't run, nor while the whole runner is
588+ // stopped, whose own Start starts it (one the person stopped can be started).
589+ can_toggle: per_kind && (capability.available || !enabled) && !(facts.held && enabled),
590+ job: None,
591+ progress: None,
592+ isolation: capability.isolation.clone().filter(|_| capability.available),
593+ runner: capability.available.then(|| format!("{}-{id}", facts.machine)),
594+ version: capability.runner_version.clone(),
401595 };
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);
596+ let said = |line: &mut CapabilityLine, state: KindState, tone: Tone, cause: Option<String>, fix: Option<String>, actions: Vec<Action>| {
597+ line.state = state;
598+ line.tone = tone;
599+ line.title = match state {
600+ KindState::Busy => "Running a job",
601+ KindState::Idle => "Waiting for jobs",
602+ KindState::Starting => "Starting",
603+ KindState::Downloading => "Downloading image",
604+ KindState::Updating => "Updating",
605+ KindState::Waiting => "Waiting for image",
606+ KindState::Stopped => "Stopped",
607+ KindState::Unavailable => "Not available",
608+ KindState::Problem => "Problem",
408609 }
409− } else if !capability.available || capability.state == State::Error {
610+ .into();
611+ line.cause = cause;
612+ line.fix = fix;
613+ line.actions = actions;
614+ };
615+ let again = |what: &str| format!("g1t {what} again by itself in a few minutes; Retry {what} now.");
616+ let starting = facts.starting.iter().any(|kind| kind == id) || matches!(capability.state, State::Starting);
617+
618+ // Stopped by the person: nothing is wrong with it.
619+ if capability.off() || (!enabled && per_kind) {
620+ said(&mut line, KindState::Stopped, Tone::Off, some(format!("You stopped {label} on this computer. They stay stopped until you start them.")), None, vec![]);
621+ return line;
622+ }
623+ // This computer can't run it, and why.
624+ if !capability.available {
625+ if id == "linux"
626+ && let Some((code, cause, fix)) = sandbox_trouble(status)
627+ {
628+ let whole = diagnose(code, &Facts { detail: Some(cause), fix, sandbox: None, ..facts.clone() });
629+ said(&mut line, KindState::Unavailable, Tone::Warning, whole.cause, whole.fix, whole.actions);
630+ line.headline = Some(headline(&whole.title));
631+ return line;
632+ }
410633 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."),
634+ let (headline, cause, fix, actions) = match cause.as_deref().map(launcher::why) {
635+ Some(Why::WindowsSandboxOff) => (
636+ "Windows Sandbox is off",
637+ some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't."),
638+ some("On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox in Windows features, then restart."),
417639 vec![WINDOWS_FEATURES],
418640 ),
419− Some(Why::NoWsb) => line(
420− Tone::Off,
421− title.into(),
641+ Some(Why::NoWsb) => (
642+ "Windows needs an update",
422643 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."),
644+ some("Update Windows to 24H2 or later, then restart g1t."),
424645 vec![],
425646 ),
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![]),
647+ Some(Why::MacNotReleased) => (
648+ "Not released yet",
649+ some("Sandboxed macOS jobs aren't released yet."),
650+ some("They come with a g1t-runner update, which g1t downloads by itself. There's nothing for you to do."),
651+ vec![],
652+ ),
653+ _ => ("Can't run here", cause, capability.fix.clone(), vec![LEARN_MORE]),
428654 };
655+ said(&mut line, KindState::Unavailable, Tone::Off, cause, fix, actions);
656+ line.headline = Some(headline.into());
657+ return line;
658+ }
659+ // Linux jobs that should run and don't, in the catalogue's words.
660+ if id == "linux"
661+ && !starting
662+ && !matches!(capability.state, State::Updating)
663+ && let Some((code, cause, fix)) = sandbox_trouble(status)
664+ {
665+ let wait_seconds = if facts.live { None } else { facts.wait_seconds };
666+ let whole = diagnose(code, &Facts { detail: Some(cause), fix, wait_seconds, sandbox: None, ..facts.clone() });
667+ let state = if code == Code::WaitingForImage { KindState::Waiting } else { KindState::Problem };
668+ said(&mut line, state, whole.tone, whole.cause, whole.fix, whole.actions);
669+ line.headline = Some(headline(&whole.title));
670+ return line;
429671 }
672+ // A native kind of job whose runner didn't start, in the launcher's words.
673+ if id != "linux" && capability.state == State::Error && !starting {
674+ let cause = capability.cause.clone().or_else(|| some("Its runner didn't start, and didn't say why."));
675+ said(&mut line, KindState::Problem, Tone::Problem, cause, some(again("tries")), vec![RETRY, VIEW_LOG]);
676+ line.headline = Some("Its runner didn't start".into());
677+ return line;
678+ }
679+ let downloading = facts.download.as_ref().filter(|_| id == "linux");
430680 match capability.state {
681+ _ if starting || capability.state == State::Updating => match downloading {
682+ Some(download) => {
683+ said(&mut line, KindState::Downloading, Tone::Working, some("Downloading and checking the sandbox's image, once: it takes a few minutes."), None, vec![]);
684+ line.progress = Some(download.percent);
685+ }
686+ None if capability.state == State::Updating => said(&mut line, KindState::Updating, Tone::Working, some("Taking the newest sandbox image; it takes jobs again when it's done."), None, vec![]),
687+ None if capability.state == State::Absent => said(&mut line, KindState::Starting, Tone::Working, some(format!("Registering it with {}.", facts.workspace.as_deref().unwrap_or("your workspace"))), None, vec![]),
688+ None => said(&mut line, KindState::Starting, Tone::Working, None, None, vec![]),
689+ },
431690 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![]),
691+ Some(job) => {
692+ said(&mut line, KindState::Busy, Tone::Working, None, None, vec![]);
693+ let page = if job.kind == "agent" { "agents" } else { "actions" };
694+ let link = (!job.repo.is_empty() && !facts.site.is_empty()).then(|| format!("{}/{}/{page}", facts.site.trim_end_matches('/'), job.repo));
695+ line.job = Some(JobLine { kind: job.kind.clone(), name: job.name.clone(), repo: job.repo.clone(), link });
696+ }
697+ None => said(&mut line, KindState::Idle, Tone::Ok, None, None, vec![]),
434698 },
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![]),
699+ // Up while the others run, yet not running: it stopped by itself,
700+ // or is to be registered.
701+ State::Stopped | State::Absent if facts.live && status.running() => {
702+ if capability.state == State::Absent {
703+ said(&mut line, KindState::Starting, Tone::Working, some(format!("Registering it with {}.", facts.workspace.as_deref().unwrap_or("your workspace"))), None, vec![]);
704+ } else {
705+ said(&mut line, KindState::Problem, Tone::Problem, some("Its runner stopped by itself."), some(again("starts it")), vec![RETRY, VIEW_LOG]);
706+ line.headline = Some("Its runner stopped".into());
707+ }
708+ }
709+ State::Stopped | State::Absent => said(&mut line, KindState::Stopped, Tone::Off, None, None, vec![]),
710+ State::Error | State::Unknown | State::Starting | State::Updating => {
711+ said(&mut line, KindState::Problem, Tone::Problem, capability.cause.clone().or_else(|| status.error.clone()), capability.fix.clone(), vec![RETRY, VIEW_LOG]);
712+ }
713+ }
714+ // The person stopped it, and this launcher can't stop one kind alone yet.
715+ if !enabled && line.fix.is_none() {
716+ line.fix = some(format!("This g1t-runner can't stop {label} on their own; the next one can, and g1t downloads it by itself."));
440717 }
718+ line
441719 }
442720
443721 /// What the site says of this computer while g1t is closed on it.
450728 fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)),
451729 actions: vec![],
452730 capabilities: vec![],
731+ in_capabilities: false,
453732 }
454733 }
455734
458737 use super::*;
459738
460739 fn facts() -> Facts {
461− Facts { workspace: Some("acme".into()), backend: "sandboxed", machine: "build-box".into(), server: "g1t.sh".into(), platform: "windows".into(), ..Facts::default() }
740+ Facts {
741+ workspace: Some("acme".into()),
742+ backend: "sandboxed",
743+ machine: "build-box".into(),
744+ server: "g1t.sh".into(),
745+ site: "https://g1t.sh".into(),
746+ platform: "windows".into(),
747+ ..Facts::default()
748+ }
462749 }
463750
464751 #[test]
465752 fn every_failure_says_why_and_how_to_fix_it() {
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,
753+ for code in [
754+ Code::SignedOut,
755+ Code::NoWorkspace,
756+ Code::NotAllowed,
757+ Code::RegisterFailed,
758+ Code::NoDocker,
759+ Code::NoLauncher,
760+ Code::NoWsl,
761+ Code::WslMirrored,
762+ Code::NoVirtualization,
763+ Code::NoVfkit,
764+ Code::NoSysbox,
765+ Code::WaitingForImage,
766+ Code::ImageFailed,
767+ Code::SandboxUnavailable,
768+ Code::SandboxFailed,
769+ Code::SandboxTaken,
770+ Code::UpdateFailed,
771+ Code::Offline,
772+ Code::Crashed,
469773 ] {
470774 let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() });
471775 assert!(diagnosis.cause.is_some(), "{code:?} has no cause");
472776 assert!(diagnosis.fix.is_some(), "{code:?} has no fix");
473777 assert!(!diagnosis.actions.is_empty(), "{code:?} has no action");
474778 assert!(matches!(diagnosis.tone, Tone::Warning | Tone::Problem), "{code:?}");
779+ // No fix sends the person off to check their connection or report it
780+ // when that isn't what is wrong.
781+ let fix = diagnosis.fix.unwrap();
782+ if !matches!(code, Code::Offline | Code::ImageFailed) {
783+ assert!(!fix.contains("check the connection") && !fix.contains("Check the connection"), "{code:?}: {fix}");
784+ }
475785 }
476786 }
477787
485795 assert_eq!(busy.cause.as_deref(), Some("Running sandboxed for acme on build-box."));
486796 let crashed = diagnose(Code::Crashed, &Facts { exit_code: Some(101), wait_seconds: Some(30), ..facts() });
487797 assert_eq!(crashed.title, "Runner stopped (exit code 101)");
798+ assert_eq!(crashed.fix.as_deref(), Some("It starts again by itself in 30 seconds. If it keeps stopping, its log says why; Restart starts it now."));
488799 assert_eq!(crashed.actions.iter().map(|a| a.id).collect::<Vec<_>>(), ["restart", "view_log"]);
489800 assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker");
490801 assert_eq!(closed(&facts()).title, "g1t is closed on build-box");
802+ assert_eq!(said_wait(300), "5 minutes");
803+ assert_eq!(said_wait(45), "45 seconds");
491804 }
492805
493806 fn status(json: &str) -> Status {
507820 let status = status(NO_WSL_PRO);
508821 let (code, cause, _) = sandbox_trouble(&status).unwrap();
509822 assert_eq!(code, Code::NoWsl);
510− let diagnosis = diagnose(code, &Facts { detail: Some(cause), sandbox: Some(status), ..facts() });
823+ let diagnosis = diagnose(code, &Facts { detail: Some(cause), sandbox: Some(status), live: true, ..facts() });
511824 assert_eq!(diagnosis.title, "Not running: WSL isn't installed");
512825 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."));
513826 assert_eq!(diagnosis.actions[0], COPY_WSL_INSTALL);
514827 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));
828+ assert_eq!((linux.label.as_str(), linux.title.as_str(), linux.state), ("Linux jobs", "Not available", KindState::Unavailable));
829+ assert_eq!(linux.headline.as_deref(), Some("WSL isn't installed"));
516830 assert_eq!(linux.fix, diagnosis.fix);
517831 assert_eq!(linux.actions[0].id, "copy_wsl_install");
518832 assert_eq!((windows.label.as_str(), windows.title.as_str(), windows.tone), ("Windows jobs", "Waiting for jobs", Tone::Ok));
833+ assert_eq!(windows.runner.as_deref(), Some("build-box-windows"));
834+ assert_eq!(windows.about, "Each job in a Windows Sandbox, discarded after");
835+ // The Linux card says it all: the whole runner's line isn't shown as well.
836+ assert!(diagnosis.in_capabilities);
837+ // An older launcher can't stop one kind of job alone.
838+ assert!(!windows.can_toggle);
519839 }
520840
521841 #[test]
524844 r#"{"schema":1,"state":"running","backend":"wsl2","runner_version":"0.3.0","launcher_version":"0.3.0",
525845 "job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"},
526846 "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…"}]}"#,
847+ {"id":"linux","available":true,"state":"running","runner_version":"0.3.0","isolation":"nested-container","turned_off":false,"job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"}},
848+ {"id":"windows","available":false,"state":"absent","turned_off":false,"cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"On Windows 11 Pro…"}]}"#,
529849 );
530850 assert_eq!(sandbox_trouble(&status), None);
531− let busy = diagnose(Code::Busy, &Facts { job: status.job().map(launcher::Job::said), sandbox: Some(status), ..facts() });
851+ let busy = diagnose(Code::Busy, &Facts { job: status.job().map(launcher::Job::said), sandbox: Some(status), live: true, ..facts() });
532852 assert_eq!(busy.title, "Running build (acme/web)");
853+ assert!(busy.in_capabilities);
533854 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."));
855+ assert_eq!((linux.title.as_str(), linux.state, linux.tone), ("Running a job", KindState::Busy, Tone::Working));
856+ let job = linux.job.as_ref().unwrap();
857+ assert_eq!((job.name.as_str(), job.repo.as_str(), job.link.as_deref()), ("build", "acme/web", Some("https://g1t.sh/acme/web/actions")));
858+ assert!(linux.can_toggle && linux.enabled);
859+ assert_eq!((windows.title.as_str(), windows.tone, windows.state), ("Not available", Tone::Off, KindState::Unavailable));
860+ assert_eq!(windows.headline.as_deref(), Some("Windows Sandbox is off"));
861+ assert_eq!(windows.cause.as_deref(), Some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't."));
538862 assert_eq!(windows.actions[0].id, "windows_features");
863+ // Nothing to start or stop where it can't run.
864+ assert!(!windows.can_toggle);
865+ assert_eq!(windows.runner, None);
866+ }
867+
868+ /// 0.3.0 was published without the sandbox's images: Linux jobs wait for
869+ /// them, plainly, while Windows jobs run.
870+ const NO_IMAGES: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.3.0",
871+ "error":"Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images",
872+ "capabilities":[
873+ {"id":"linux","available":true,"state":"error","isolation":"nested-container","turned_off":false},
874+ {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.3.0","turned_off":false}]}"#;
875+
876+ #[test]
877+ fn a_release_without_images_is_waited_for_not_reported() {
878+ let status = status(NO_IMAGES);
879+ let (code, cause, _) = sandbox_trouble(&status).unwrap();
880+ assert_eq!(code, Code::WaitingForImage);
881+ let whole = diagnose(code, &Facts { detail: Some(cause), ..facts() });
882+ assert_eq!(whole.title, "Waiting for the sandbox's image");
883+ assert_eq!(whole.cause.as_deref(), Some("g1t-runner 0.3.0 is published without the sandbox's images yet, so Linux jobs can't start."));
884+ assert_eq!(whole.fix.as_deref(), Some("g1t checks again every few minutes and starts Linux jobs once they're out. There's nothing for you to do."));
885+ assert_eq!(whole.actions, [CHECK_NOW]);
886+ assert!(!whole.fix.unwrap().contains("report"));
887+ // Windows jobs run: the runner waits for jobs, and the Linux card says what it waits for.
888+ let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status), live: true, ..facts() });
889+ assert!(idle.in_capabilities);
890+ let [linux, windows] = &idle.capabilities[..] else { panic!() };
891+ assert_eq!((linux.state, linux.title.as_str(), linux.tone), (KindState::Waiting, "Waiting for image", Tone::Warning));
892+ assert_eq!(linux.headline.as_deref(), Some("Waiting for the sandbox's image"));
893+ assert_eq!(windows.state, KindState::Idle);
894+ let image = diagnose(Code::ImageFailed, &Facts { detail: Some("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it".into()), ..facts() });
895+ assert_eq!(image.title, "Not running: the sandbox's image didn't check out");
896+ let fetch = diagnose(Code::ImageFailed, &Facts { detail: Some("could not fetch https://g1t.sh/x: dns error".into()), wait_seconds: Some(60), ..facts() });
897+ assert_eq!(fetch.cause.as_deref(), Some("The sandbox's image couldn't be downloaded: could not fetch https://g1t.sh/x: dns error"));
898+ assert_eq!(fetch.fix.as_deref(), Some("g1t tries again by itself in 60 seconds. Check that this computer can reach g1t.sh; Retry tries now."));
539899 }
540900
541901 #[test]
902+ fn a_kind_the_person_stopped_says_so_and_is_not_failing() {
903+ let status = status(
904+ r#"{"schema":1,"state":"running","backend":"wsl2","capabilities":[
905+ {"id":"linux","available":true,"state":"running","turned_off":false},
906+ {"id":"windows","available":true,"state":"stopped","turned_off":true}]}"#,
907+ );
908+ let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status.clone()), live: true, off: vec!["windows".into()], ..facts() });
909+ let [linux, windows] = &idle.capabilities[..] else { panic!() };
910+ assert_eq!(linux.state, KindState::Idle);
911+ assert_eq!((windows.state, windows.tone, windows.enabled), (KindState::Stopped, Tone::Off, false));
912+ assert_eq!(windows.cause.as_deref(), Some("You stopped Windows jobs on this computer. They stay stopped until you start them."));
913+ assert!(windows.can_toggle && windows.actions.is_empty());
914+ // Stopped by itself while Linux jobs run is a problem, with Retry.
915+ let died = launcher::parse(&serde_json::to_string(&serde_json::json!({"schema":1,"state":"running","capabilities":[
916+ {"id":"linux","available":true,"state":"running","turned_off":false},
917+ {"id":"windows","available":true,"state":"stopped","turned_off":false}]})).unwrap()).unwrap();
918+ let idle = diagnose(Code::Idle, &Facts { sandbox: Some(died.clone()), live: true, ..facts() });
919+ assert_eq!((idle.capabilities[1].state, idle.capabilities[1].actions[0].id), (KindState::Problem, "retry"));
920+ // With the whole runner stopped, it is only stopped.
921+ let stopped = diagnose(Code::Stopped, &Facts { sandbox: Some(died), live: false, held: true, ..facts() });
922+ // Its own Start or Stop waits for the runner's: the line above the cards starts it.
923+ assert!(!stopped.capabilities[0].can_toggle);
924+ assert_eq!(stopped.capabilities[1].state, KindState::Stopped);
925+ assert!(!stopped.in_capabilities);
926+ }
927+
928+ #[test]
929+ fn a_download_shows_how_far_it_is() {
930+ let status = status(r#"{"schema":1,"state":"starting","capabilities":[{"id":"linux","available":true,"state":"starting","turned_off":false},{"id":"windows","available":true,"state":"absent","turned_off":false}]}"#);
931+ let facts = Facts { sandbox: Some(status), live: true, starting: vec!["linux".into(), "windows".into()], download: Some(launcher::Download { key: "wsl-x64".into(), percent: 40 }), ..facts() };
932+ let starting = diagnose(Code::Starting, &facts);
933+ assert!(starting.in_capabilities);
934+ let [linux, windows] = &starting.capabilities[..] else { panic!() };
935+ assert_eq!((linux.state, linux.title.as_str(), linux.progress), (KindState::Downloading, "Downloading image", Some(40)));
936+ assert_eq!((windows.state, windows.progress), (KindState::Starting, None));
937+ }
938+
939+ #[test]
542940 fn each_launcher_trouble_has_its_words() {
543941 let trouble = |linux_available: bool, text: &str| {
544942 let json = serde_json::json!({
552950 assert_eq!(trouble(false, "This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.").0, Code::NoSysbox);
553951 assert_eq!(trouble(false, "WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.").0, Code::WslMirrored);
554952 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);
953+ assert_eq!(trouble(true, "release 0.3.0 has no sandbox images").0, Code::WaitingForImage);
555954 // Unknown words: shown as the launcher wrote them, its fix with them.
556955 let (code, cause, fix) = trouble(false, "This user may not use /dev/kvm.");
557956 assert_eq!(code, Code::SandboxUnavailable);
562961 assert_eq!(code, Code::SandboxFailed);
563962 let failed = diagnose(code, &Facts { detail: Some(cause), wait_seconds: Some(60), ..facts() });
564963 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"));
964+ assert_eq!(failed.fix.as_deref(), Some("It starts again by itself in 60 seconds. If it keeps stopping, its log says why; Retry starts it now."));
568965 assert_eq!(diagnose(Code::NoVirtualization, &Facts { platform: "macos".into(), ..facts() }).actions[0].id, "retry");
569966 assert_eq!(diagnose(Code::NoVfkit, &facts()).actions[0].id, "copy_vfkit_install");
570967 }
573970 fn a_diagnosis_without_the_sandbox_lists_no_capabilities() {
574971 let json = serde_json::to_value(diagnose(Code::Idle, &Facts { backend: "in Docker", ..facts() })).unwrap();
575972 assert!(json.get("capabilities").is_none());
973+ assert!(json.get("in_capabilities").is_none());
576974 let sandboxed = serde_json::to_value(diagnose(Code::Idle, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() })).unwrap();
577975 assert_eq!(sandboxed["capabilities"][1]["id"], "windows");
976+ assert_eq!(sandboxed["capabilities"][1]["state"], "idle");
977+ assert_eq!(sandboxed["in_capabilities"], true);
978+ // What affects everything is said once, above the cards.
979+ let signed_out = diagnose(Code::SignedOut, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() });
980+ assert!(!signed_out.in_capabilities);
578981 }
579982 }
+156−16
2020 //! - `down` as g1t quits or the person stops it; `down --drain` when they
2121 //! choose another way of running jobs; `down --delete` when the
2222 //! registration is no longer the one wanted.
23+//! - `up --only linux` and `down --only windows` when the person starts
24+//! or stops one kind of job in settings: the other is left as it is.
25+//! Every `up` names the kinds that are on (`--only`), so the launcher
26+//! never starts one the person stopped. A launcher from before `--only`
27+//! says no `turned_off` in its status, and is never sent `down --only`,
28+//! which it would read as `down`, stopping everything.
2329 //! - `update`, while nothing runs: the newest signed image, the same
2430 //! registration.
2531 //!
4551 pub const WSL_INSTALL: &str = "wsl --install --no-distribution";
4652 /// What installs vfkit on a Mac.
4753 pub const VFKIT_INSTALL: &str = "brew install vfkit";
54+/// How often the app looks again for a release with the sandbox's images,
55+/// while the newest is published without them.
56+pub const IMAGES_AGAIN: Duration = Duration::from_secs(5 * 60);
4857
4958 /// A sandbox's, or one capability's, state.
5059 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
95104 pub cause: Option<String>,
96105 /// What to do about it, in a sentence.
97106 pub fix: Option<String>,
107+ /// The person stopped it (`down --only`): stopped, not failing. None
108+ /// from a launcher before it could stop one kind of job on its own.
109+ pub turned_off: Option<bool>,
98110 }
99111
112+impl Capability {
113+ pub fn off(&self) -> bool {
114+ self.turned_off == Some(true)
115+ }
116+}
117+
100118 /// `g1t-runner sandbox status --json`. The top-level fields are the Linux
101119 /// VM's. Fields the app does not use (`resources`, `state_dir`) are left
102120 /// out, and any a newer launcher adds are ignored.
144162 if self.capabilities.is_empty() { self.linux().available } else { self.capabilities.iter().any(|c| c.available) }
145163 }
146164
147− /// A capability that can be had here but was never set up: registering
148− /// it needs a token.
165+ /// A capability that can be had here, and is on, but was never set
166+ /// up: registering it needs a token.
149167 pub fn unregistered(&self) -> bool {
150− let wanting = |c: &Capability| c.available && c.state == State::Absent;
168+ let wanting = |c: &Capability| c.available && !c.off() && c.state == State::Absent;
151169 if self.capabilities.is_empty() { wanting(&self.linux()) } else { self.capabilities.iter().any(wanting) }
152170 }
153171
172+ /// Whether this launcher starts and stops each kind of job on its own
173+ /// (`--only`), which it says by saying which are turned off.
174+ pub fn per_kind(&self) -> bool {
175+ !self.capabilities.is_empty() && self.capabilities.iter().all(|c| c.turned_off.is_some())
176+ }
177+
178+ /// Whether everything that could run here was turned off by the person.
179+ pub fn all_off(&self) -> bool {
180+ let available: Vec<&Capability> = self.capabilities.iter().filter(|c| c.available || c.off()).collect();
181+ !available.is_empty() && available.iter().all(|c| c.off())
182+ }
183+
154184 /// What a runner is running, the Linux one's first.
155185 pub fn job(&self) -> Option<&Job> {
156186 self.job.as_ref().or_else(|| self.capabilities.iter().find_map(|c| c.job.as_ref()))
168198 /// else why the capability is unavailable.
169199 pub fn trouble(&self) -> Option<(String, Option<String>)> {
170200 let linux = self.linux();
201+ // Stopped by the person: nothing is wrong with it.
202+ if linux.off() {
203+ return None;
204+ }
171205 if !linux.available {
172206 let cause = linux.cause.or_else(|| self.error.clone())?;
173207 return Some((cause, linux.fix));
203237 line.starts_with('{')
204238 }
205239
240+/// How far the sandbox's image has downloaded, from the launcher's log.
241+#[derive(Clone, Debug, PartialEq, Eq)]
242+pub struct Download {
243+ /// `wsl-x64`.
244+ pub key: String,
245+ pub percent: u8,
246+}
247+
248+/// Reads `Fetching the sandbox's wsl-x64 image (612 MB)`, the start of a
249+/// download, and `Fetching the sandbox's wsl-x64 image: 40% of 612 MB`,
250+/// each tenth of it (the launcher's image.rs), after the log's time.
251+pub fn download(line: &str) -> Option<Download> {
252+ let (_, rest) = line.split_once("Fetching the sandbox's ")?;
253+ let (key, rest) = rest.split_once(" image")?;
254+ let percent = match rest.strip_prefix(": ") {
255+ Some(said) => said.split_once('%')?.0.trim().parse::<u8>().ok()?.min(100),
256+ None => 0,
257+ };
258+ Some(Download { key: key.to_owned(), percent })
259+}
260+
206261 /// What `up` is told the first time, and again on every start (the
207262 /// launcher keeps what it was told, and `--token` only matters once).
208263 #[derive(Clone, PartialEq, Eq)]
212267 pub labels: &'a str,
213268 /// The registration token: only for a sandbox that has no registration yet.
214269 pub token: Option<&'a str>,
270+ /// The kinds of job to start (`--only`): those the person has on.
271+ pub only: Vec<&'a str>,
215272 }
216273
217274 // Never print a token, in a log or a panic.
218275 impl std::fmt::Debug for Up<'_> {
219276 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()
277+ f.debug_struct("Up")
278+ .field("url", &self.url)
279+ .field("name", &self.name)
280+ .field("labels", &self.labels)
281+ .field("token", &self.token.map(|_| "…"))
282+ .field("only", &self.only)
283+ .finish()
221284 }
222285 }
223286
236299 args.push(name.into());
237300 args.push(value.into());
238301 }
302+ if !up.only.is_empty() {
303+ args.push("--only".into());
304+ args.push(up.only.join(",").into());
305+ }
239306 args
240307 }
241308
268335 args
269336 }
270337
338+/// `down --only <kind>`: that kind of job stops, and stays off; the others
339+/// are left as they are. Only for a launcher whose status is [`Status::per_kind`].
340+pub fn down_only_args(dir: &Path, kind: &str) -> Vec<OsString> {
341+ let mut args = args("down", dir);
342+ args.push("--only".into());
343+ args.push(kind.into());
344+ args
345+}
346+
271347 pub fn status_args(dir: &Path) -> Vec<OsString> {
272348 args("status", dir)
273349 }
293369 /// Runs a short launcher command to its end and reads its status, which
294370 /// it prints whether it worked or not.
295371 fn run(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
372+ run_done(binary, args).map(|(status, _)| status)
373+}
374+
375+/// [`run`], and whether the command did what it was asked (its exit code).
376+fn run_done(binary: &Path, args: Vec<OsString>) -> Result<(Status, bool), String> {
296377 let out = command(binary, args).output().map_err(|e| format!("g1t-runner could not start: {e}"))?;
297378 let printed = String::from_utf8_lossy(&out.stdout);
298379 match parse(&printed) {
299− Ok(status) => Ok(status),
380+ Ok(status) => Ok((status, out.status.success())),
300381 Err(_) if !out.status.success() && !out.stderr.is_empty() => Err(String::from_utf8_lossy(&out.stderr).trim().trim_start_matches("g1t-runner: ").to_owned()),
301382 Err(why) => Err(why),
302383 }
307388 run(binary, status_args(dir))
308389 }
309390
310−/// Stops the sandbox, waiting for it.
311−pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<(), String> {
391+/// Stops the sandbox, waiting for it: how it is now.
392+pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<Status, String> {
393+ down_with(binary, down_args(dir, how))
394+}
395+
396+/// Stops one kind of job, and keeps it off, waiting for it.
397+pub fn down_only(binary: &Path, dir: &Path, kind: &str) -> Result<Status, String> {
398+ down_with(binary, down_only_args(dir, kind))
399+}
400+
401+fn down_with(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
312402 if !binary.exists() {
313403 return Err("g1t-runner isn't installed".into());
314404 }
315− let status = run(binary, down_args(dir, how))?;
316− match status.error {
317− Some(error) => Err(error),
318− None => Ok(()),
405+ match run_done(binary, args)? {
406+ (status, true) => Ok(status),
407+ (status, false) => Err(status.error.unwrap_or_else(|| "g1t-runner sandbox down failed.".into())),
319408 }
320409 }
321410
330419 NoVfkit,
331420 /// Linux, with neither Sysbox in Docker nor KVM.
332421 NoSysboxOrKvm,
422+ /// The newest release is published without the sandbox's images (for
423+ /// this computer), so there is nothing to download yet.
424+ NoImages,
333425 /// The sandbox's image couldn't be downloaded, or isn't the release's.
334426 Image,
335427 /// g1t refused the registration token.
348440 Other,
349441 }
350442
443+/// The release a "has no sandbox images" sentence names, and the image
444+/// it lacks when it is only this computer's: `release 0.3.0 has no
445+/// sandbox image vm-arm64-disk for this machine`.
446+pub fn missing_images(text: &str) -> Option<(String, Option<String>)> {
447+ let (before, after) = text.split_once(" has no sandbox image")?;
448+ let version = before.rsplit("release ").next()?.trim().to_owned();
449+ let image = after.strip_prefix(' ').and_then(|rest| rest.split_whitespace().next()).map(str::to_owned);
450+ (!version.is_empty() && !version.contains(' ')).then_some((version, image))
451+}
452+
351453 pub fn why(text: &str) -> Why {
352454 let lower = text.to_ascii_lowercase();
353455 let has = |words: &str| lower.contains(words);
373475 Why::Forgotten
374476 } else if has("is not registered") {
375477 Why::Unregistered
478+ } else if has("has no sandbox image") {
479+ Why::NoImages
376480 } 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") {
377481 Why::Image
378482 } else if has("windows sandbox is not turned on") {
515619 ("This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.", Why::NoSysboxOrKvm),
516620 ("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it", Why::Image),
517621 ("could not fetch https://g1t.sh/downloads/runner/0.3.0/wsl-x64.tar.gz: dns error", Why::Image),
518− ("release 0.3.0 has no sandbox images", Why::Image),
622+ ("release 0.3.0 has no sandbox images", Why::NoImages),
623+ ("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images", Why::NoImages),
624+ ("release 0.3.0 has no sandbox image vm-arm64-disk for this machine", Why::NoImages),
519625 ("The registration token is not valid, or has expired.", Why::TokenRefused),
520626 ("g1t no longer knows this runner (401 unauthorized). Register it again.", Why::Forgotten),
521627 ("the sandbox's runner is not registered, and no --url and --token were given", Why::Unregistered),
528634 }
529635
530636 #[test]
637+ fn a_kind_turned_off_is_stopped_by_the_person() {
638+ let status = parse(
639+ r#"{"schema":1,"state":"stopped","capabilities":[
640+ {"id":"linux","available":true,"state":"stopped","turned_off":true},
641+ {"id":"windows","available":true,"state":"absent","turned_off":false}]}"#,
642+ )
643+ .unwrap();
644+ assert!(status.per_kind());
645+ assert!(status.capability("linux").unwrap().off());
646+ assert!(!status.all_off());
647+ assert_eq!(status.trouble(), None);
648+ // Only Windows, never set up, wants registering; Linux is off.
649+ assert!(status.unregistered());
650+ let all = parse(r#"{"schema":1,"state":"stopped","capabilities":[{"id":"linux","available":true,"state":"stopped","turned_off":true},{"id":"windows","available":false,"state":"absent","turned_off":false}]}"#).unwrap();
651+ assert!(all.all_off());
652+ assert!(!all.unregistered());
653+ // A launcher from before `--only` says nothing of it.
654+ assert!(!parse(RUNNING).unwrap().per_kind());
655+ }
656+
657+ #[test]
658+ fn a_download_says_how_far_it_is() {
659+ assert_eq!(download("2026-10-12T08:00:00Z Fetching the sandbox's wsl-x64 image (612 MB)"), Some(Download { key: "wsl-x64".into(), percent: 0 }));
660+ assert_eq!(download("2026-10-12T08:01:00Z Fetching the sandbox's wsl-x64 image: 40% of 612 MB"), Some(Download { key: "wsl-x64".into(), percent: 40 }));
661+ assert_eq!(download("2026-10-12T08:01:00Z Took build (acme/web)"), None);
662+ assert_eq!(missing_images("release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None)));
663+ assert_eq!(missing_images("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None)));
664+ assert_eq!(missing_images("release 0.4.0 has no sandbox image vm-arm64-disk for this machine"), Some(("0.4.0".into(), Some("vm-arm64-disk".into()))));
665+ assert_eq!(missing_images("could not fetch"), None);
666+ }
667+
668+ #[test]
531669 fn the_commands_are_the_launchers() {
532670 let dir = PathBuf::from("data").join("runner").join(FOLDER);
533671 let text = |args: Vec<OsString>| args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
534− let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret") };
672+ let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret"), only: vec!["linux", "windows"] };
535673 let up = text(up_args(&dir, &first));
536674 assert_eq!(&up[..4], ["sandbox", "up", "--json", "--state-dir"]);
537675 assert_eq!(up[4], dir.to_string_lossy());
538676 // Never the token: it goes in the environment, out of the process list.
539− assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop"]);
677+ assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--only", "linux,windows"]);
678+ assert_eq!(text(down_only_args(&dir, "windows"))[5..], ["--only", "windows"]);
540679 assert!(!up.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
541680 let mut command = command(Path::new("g1t-runner"), up_args(&dir, &first));
542681 with_token(&mut command, first.token);
543682 assert!(command.get_envs().any(|(name, value)| name == "G1T_RUNNER_TOKEN" && value == Some(std::ffi::OsStr::new("g1trt_secret"))));
544683 assert!(!command.get_args().any(|a| a.to_string_lossy().contains("g1trt_")));
545684 // Its name and labels as the app has them: the launcher adds `-linux` and the OS labels.
546− assert!(!up.iter().any(|a| a.ends_with("-linux") || a.contains("windows")));
547− let again = text(up_args(&dir, &Up { token: None, ..first.clone() }));
685+ assert!(!up.iter().any(|a| a.ends_with("-linux")));
686+ let again = text(up_args(&dir, &Up { token: None, only: vec![], ..first.clone() }));
687+ assert!(!again.iter().any(|a| a == "--only"));
548688 assert!(!again.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
549689 assert!(!format!("{first:?}").contains("g1trt_secret"));
550690 assert_eq!(text(down_args(&dir, Down::Now))[..3], ["sandbox", "down", "--json"]);
+316−34
Loading the diff…
+55−2
Loading the diff…
+27−11
Loading the diff…
+8−4
Loading the diff…
+198−48
Loading the diff…
+1−1
Loading the diff…
+79−1
Loading the diff…
+69−10
Loading the diff…
+41−1
Loading the diff…
+36−3
Loading the diff…
+269−45
Loading the diff…
+34−2
Loading the diff…
+10−1
Loading the diff…