Skip to content

Commit

Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.

syntaqxcommitted Parentsacf6e66894c28bBrowse 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…