Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

The desktop app's runner shows itself everywhere a person looks for it. Its settings window has a Runner section: whether it runs jobs while g1t is open, what it is doing now, and when it isn't running why and the fix, with the fix's buttons (Open Docker, Sign in, Choose workspace, Restart, View log and the rest); how jobs run, sandboxed, in Docker, or directly on this computer, each with plainly what it can reach; the workspace it runs for; this computer's name, the runner's version, View on g1t, and the runner's recent log.

The site registers it with nothing typed. On every page inside the app, the page tells the app who is signed in, the workspaces they own and the one they are in; when the app asks, the page makes a registration token for that workspace at POST /-/desktop/runner, owners only and same origin, and hands it straight to the app, never showing it. Someone who isn't an owner of the workspace is told so, with a way to choose another. A workspace's Runners page lists its Desktops: every person's computer with the app, with the state, why and the fix in the app's own words, which each app reports as it changes and every few minutes while g1t is open. One whose g1t is closed says g1t is closed on that computer, not a bare Offline, here and in the runners list for a runner labelled desktop. Inside the app, this computer leads the list, live, with its fix buttons. The Your desktop card is no longer marked as coming. The actions service keeps the reports in a new desktop_runners table (migration 0018), and a runner now records who made the token that registered it.

syntaqxcommitted Parentfc04f29Browse files
21 files+1020−110/21 viewed
+79−0
225225 .foot {
226226 text-align: center;
227227 }
228+code {
229+ font: 0.8125rem ui-monospace, SFMono-Regular, Consolas, monospace;
230+}
231+/* The runner's state: a dot in its tone, what it is doing, and when it
232+ isn't running, why and the fix, with its buttons. */
233+.diagnosis {
234+ display: flex;
235+ align-items: flex-start;
236+ gap: 0.75rem;
237+ margin: 0.25rem 0 0.5rem;
238+ padding: 0.75rem;
239+ border: 1px solid var(--line);
240+ border-radius: 10px;
241+ background: var(--surface);
242+}
243+.diagnosis-text {
244+ display: flex;
245+ flex-direction: column;
246+ gap: 0.25rem;
247+ min-width: 0;
248+ flex: 1;
249+ font-size: 0.8125rem;
250+}
251+.diagnosis-text strong {
252+ font-size: 0.875rem;
253+ font-weight: 500;
254+}
255+.diagnosis-text .buttons {
256+ display: flex;
257+ flex-wrap: wrap;
258+ gap: 0.5rem;
259+ margin-top: 0.25rem;
260+}
261+.diagnosis-text .buttons:empty {
262+ display: none;
263+}
264+.dot {
265+ flex: none;
266+ width: 8px;
267+ height: 8px;
268+ margin-top: 0.4rem;
269+ border-radius: 50%;
270+ background: var(--muted);
271+}
272+.dot.ok {
273+ background: #4ade80;
274+}
275+.dot.working {
276+ background: #60a5fa;
277+}
278+.dot.warning {
279+ background: #fbbf24;
280+}
281+.dot.problem {
282+ background: var(--error);
283+}
284+.warning-note {
285+ color: #fbbf24;
286+}
287+details.log {
288+ padding: 0.75rem 0;
289+}
290+details.log summary {
291+ color: var(--muted);
292+ font-size: 0.8125rem;
293+}
294+details.log pre {
295+ max-height: 12rem;
296+ overflow: auto;
297+ margin: 0.5rem 0;
298+ padding: 0.5rem;
299+ border-radius: 8px;
300+ background: #050507;
301+ color: var(--muted);
302+ font: 0.75rem/1.4 ui-monospace, SFMono-Regular, Consolas, monospace;
303+ white-space: pre-wrap;
304+ overflow-wrap: anywhere;
305+ user-select: text;
306+}
+43−0
4444 </label>
4545 </section>
4646
47+ <section aria-labelledby="runner-heading" id="runner-section">
48+ <h2 id="runner-heading">Runner</h2>
49+ <label class="setting">
50+ <span
51+ ><strong>Run jobs on this computer while g1t is open</strong
52+ ><span class="muted">Your workspace's <code>runs-on: self-hosted</code> jobs run here, at no cost, while g1t is open.</span></span
53+ >
54+ <input type="checkbox" role="switch" id="runner" />
55+ </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">
58+ <span class="dot" id="runner-dot" aria-hidden="true"></span>
59+ <span class="diagnosis-text">
60+ <strong id="runner-title">Runner</strong>
61+ <span class="muted" id="runner-cause"></span>
62+ <span id="runner-fix"></span>
63+ <span class="buttons" id="runner-actions"></span>
64+ </span>
65+ <button type="button" id="runner-toggle">Stop</button>
66+ </div>
67+ <label class="setting">
68+ <span><strong>How jobs run</strong><span class="muted" id="runner-backend-note">&nbsp;</span></span>
69+ <select id="runnerBackend">
70+ <option value="sandboxed">Sandboxed (recommended)</option>
71+ <option value="docker">In Docker</option>
72+ <option value="host">Directly on this computer</option>
73+ </select>
74+ </label>
75+ <label class="setting">
76+ <span><strong>Workspace</strong><span class="muted" id="runner-workspace-note">The workspace whose jobs it runs. Only a workspace's owners can add runners to it.</span></span>
77+ <select id="runnerWorkspace"></select>
78+ </label>
79+ <div class="setting">
80+ <span><strong id="runner-machine">This computer</strong><span class="muted" id="runner-version">&nbsp;</span></span>
81+ <span class="buttons"><button type="button" id="runner-view">View on g1t</button></span>
82+ </div>
83+ <details class="log">
84+ <summary>Recent log</summary>
85+ <pre id="runner-log">Nothing yet.</pre>
86+ <button type="button" id="runner-open-log">Open the whole log</button>
87+ </details>
88+ </section>
89+
4790 <section aria-labelledby="notifications-heading">
4891 <h2 id="notifications-heading">Notifications</h2>
4992 <label class="setting">
+97−1
22 // the moment it is made. Everything it asks is a command in
33 // src/bridge.rs, answered only to this window.
44 (function () {
5− var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate"];
5+ var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate", "runner"];
6+ // What each way of running jobs can reach, said plainly (src/runner/backend.rs).
7+ var BACKENDS = {
8+ sandboxed: { text: "The runner and every job run in a sandbox of their own. Nothing a job does reaches this computer.", warn: false },
9+ docker: {
10+ text: "Each job runs in a container of its own, but the runner controls this computer's Docker, which can reach your files. Not a sandbox.",
11+ warn: true,
12+ },
13+ host: {
14+ text: "Jobs run directly on this computer, as you: they can read, change and delete anything your account can. For jobs that need this system itself, such as building a Windows installer.",
15+ warn: true,
16+ },
17+ };
618 var current = null;
719
820 function byId(id) {
5769 byId("version").textContent = "g1t " + view.version;
5870 if (!checking) updateStatus(view);
5971 byId("server").textContent = hostOf(view.server);
72+ renderRunner(view.runnerView);
6073 }
6174
6275 function settingsOf() {
6578 settings[key] = byId(key).checked;
6679 });
6780 settings.zoom = Number(byId("zoom").value) || 1;
81+ settings.runnerBackend = byId("runnerBackend").value || "sandboxed";
82+ settings.runnerWorkspace = byId("runnerWorkspace").value || null;
6883 return settings;
6984 }
7085
86+ // ── The runner ──────────────────────────────────────────────────────
87+ // Its diagnosis, from src/runner/diagnosis.rs, as the tray and the
88+ // site's Runners page show it; its buttons are the fixes.
89+ function option(value, text) {
90+ var item = document.createElement("option");
91+ item.value = value;
92+ item.textContent = text;
93+ return item;
94+ }
95+
96+ function renderRunner(runner) {
97+ if (!runner) return;
98+ var diagnosis = runner.diagnosis;
99+ byId("runner-dot").className = "dot " + diagnosis.tone;
100+ byId("runner-title").textContent = diagnosis.title;
101+ byId("runner-cause").textContent = diagnosis.cause || "";
102+ byId("runner-fix").textContent = diagnosis.fix || "";
103+ var actions = byId("runner-actions");
104+ actions.textContent = "";
105+ diagnosis.actions.forEach(function (action, at) {
106+ var button = document.createElement("button");
107+ button.type = "button";
108+ button.textContent = action.label;
109+ if (at === 0) button.className = "primary";
110+ button.addEventListener("click", function () {
111+ // Choosing is done here, in this window.
112+ if (action.id === "choose_workspace") return byId("runnerWorkspace").focus();
113+ if (action.id === "choose_backend") return byId("runnerBackend").focus();
114+ invoke("runner_control", { action: action.id }).catch(fail);
115+ });
116+ actions.appendChild(button);
117+ });
118+ byId("runner").checked = !!runner.enabled;
119+ var on = runner.enabled && !runner.paused;
120+ byId("runner-toggle").textContent = on ? "Stop" : "Start";
121+ byId("runner-toggle").dataset.action = on ? "stop" : "start";
122+
123+ var backend = byId("runnerBackend");
124+ backend.value = runner.backend;
125+ var about = BACKENDS[runner.backend] || BACKENDS.sandboxed;
126+ byId("runner-backend-note").textContent = about.text;
127+ byId("runner-backend-note").className = about.warn ? "warning-note" : "muted";
128+
129+ var workspaces = byId("runnerWorkspace");
130+ workspaces.textContent = "";
131+ var known = runner.workspaces.slice();
132+ var listed = known.some(function (w) {
133+ return w.slug === runner.workspace;
134+ });
135+ if (runner.workspace && !listed) known.unshift({ slug: runner.workspace, name: null });
136+ known.forEach(function (w) {
137+ workspaces.appendChild(option(w.slug, w.name ? w.name + " (" + w.slug + ")" : w.slug));
138+ });
139+ if (known.length === 0) workspaces.appendChild(option("", "Open g1t, signed in, to choose"));
140+ workspaces.value = runner.workspace || "";
141+ workspaces.disabled = known.length === 0;
142+
143+ byId("runner-machine").textContent = "This computer: " + runner.machine;
144+ byId("runner-version").textContent = runner.version ? "g1t-runner " + runner.version : " ";
145+ byId("runner-view").hidden = !runner.link;
146+ byId("runner-log").textContent = runner.log.length ? runner.log.join("\n") : "Nothing yet.";
147+ }
148+
149+ byId("runnerBackend").addEventListener("change", save);
150+ byId("runnerWorkspace").addEventListener("change", save);
151+ byId("runner-toggle").addEventListener("click", function (event) {
152+ invoke("runner_control", { action: event.target.dataset.action || "start" }).catch(fail);
153+ });
154+ byId("runner-view").addEventListener("click", function () {
155+ invoke("runner_control", { action: "view_runners" }).catch(fail);
156+ });
157+ byId("runner-open-log").addEventListener("click", function () {
158+ invoke("runner_control", { action: "view_log" }).catch(fail);
159+ });
160+
71161 function save() {
72162 invoke("save_settings", { settings: settingsOf() }).then(render, function (error) {
73163 fail(error);
152242 if (events && typeof events.listen === "function") {
153243 events.listen("g1t-check-updates", check).catch(function () {});
154244 events
245+ .listen("g1t-runner-status", function (event) {
246+ if (current) current.runnerView = event.payload;
247+ renderRunner(event.payload);
248+ })
249+ .catch(function () {});
250+ events
155251 .listen("g1t-update-ready", function (event) {
156252 if (!checking && typeof event.payload === "string") ready(event.payload);
157253 })
+65−0
5353 .catch(function () {});
5454 }
5555
56+ // An app event, to a listener; returns what stops listening.
57+ function on(name, listener) {
58+ var events = window.__TAURI__ && window.__TAURI__.event;
59+ if (!events || typeof events.listen !== "function") return function () {};
60+ var stop = null;
61+ var stopped = false;
62+ events
63+ .listen(name, function (event) {
64+ try {
65+ listener(event && event.payload);
66+ } catch (error) {
67+ console.warn("g1t desktop: a " + name + " listener failed", error);
68+ }
69+ })
70+ .then(function (unlisten) {
71+ if (stopped) unlisten();
72+ else stop = unlisten;
73+ })
74+ .catch(function () {});
75+ return function () {
76+ stopped = true;
77+ if (stop) stop();
78+ };
79+ }
80+
5681 var bridge = {
5782 version: VERSION,
5883 platform: PLATFORM,
101126 // Whether g1t draws the title bar, and how tall it is: 0 under the system's.
102127 // The page is below it, never under it: this says only that the bar is there.
103128 titleBarHeight: FRAME === "custom" ? 36 : 0,
129+ // The runner the app keeps on this computer (src/runner/mod.rs).
130+ runner: Object.freeze({
131+ // What it is doing: its diagnosis (why it isn't running, and the
132+ // fix), the workspace, how jobs run, and its last log lines.
133+ status: function () {
134+ return invoke("runner_status");
135+ },
136+ // Who is signed in, the workspaces they own, and the one they are in;
137+ // resolves to the workspace a registration token is wanted for, or null.
138+ offer: function (offer) {
139+ return invoke("runner_offer", {
140+ signedIn: !!offer.signedIn,
141+ workspaces: (offer.workspaces || []).map(function (w) {
142+ return { slug: String(w.slug), name: w.name == null ? null : String(w.name) };
143+ }),
144+ current: offer.current == null ? null : String(offer.current),
145+ });
146+ },
147+ // A registration token for the workspace the app asked for, or why there is none.
148+ register: function (answer) {
149+ return invoke("runner_register", {
150+ workspace: String(answer.workspace),
151+ token: answer.token == null ? null : String(answer.token),
152+ allowed: answer.allowed !== false,
153+ error: answer.error == null ? null : String(answer.error),
154+ });
155+ },
156+ // Start, stop, or a diagnosis's fix, by its id.
157+ act: function (action) {
158+ return invoke("runner_control", { action: String(action) });
159+ },
160+ // The app wants a registration token for a workspace.
161+ onWanted: function (listener) {
162+ return on("g1t-runner-wanted", listener);
163+ },
164+ // The runner's status changed.
165+ onStatus: function (listener) {
166+ return on("g1t-runner-status", listener);
167+ },
168+ }),
104169 };
105170 Object.defineProperty(window, "g1tDesktop", { value: Object.freeze(bridge), writable: false, configurable: false });
106171
+84−0
1+import { useEffect, useRef } from "react";
2+
3+import type { DesktopReport } from "@g1t/contracts";
4+
5+import { type DesktopRunnerOffer, desktopRunner, reportDue, reportKey, reportOf } from "../lib/desktop-runner";
6+
7+/**
8+ * Inside the desktop app, on every page: the link between the app's runner
9+ * and the site (lib/desktop-runner.ts). It tells the app who is signed in
10+ * and what they own, makes the registration token the app asks for, and
11+ * keeps the server told how the runner is. It draws nothing.
12+ */
13+export function DesktopRunnerLink({ offer }: { offer: DesktopRunnerOffer }) {
14+ const key = JSON.stringify(offer);
15+ const last = useRef<{ key: string; at: number } | null>(null);
16+
17+ // Registering: the token goes straight from the server to the app.
18+ useEffect(() => {
19+ const runner = desktopRunner();
20+ if (!runner) return;
21+ let asking: string | null = null;
22+ const signedIn = (JSON.parse(key) as DesktopRunnerOffer).signedIn;
23+ const register = async (workspace: string) => {
24+ // Signed out, the app says so itself, from the offer.
25+ if (!signedIn || asking === workspace) return;
26+ asking = workspace;
27+ try {
28+ const response = await fetch("/-/desktop/runner", {
29+ method: "POST",
30+ headers: { "content-type": "application/json" },
31+ body: JSON.stringify({ intent: "token", workspace }),
32+ });
33+ const answer = (await response.json().catch(() => ({}))) as { token?: string; error?: string; allowed?: boolean };
34+ await runner.register(
35+ answer.token ? { workspace, token: answer.token } : { workspace, allowed: answer.allowed !== false, error: answer.error ?? `g1t answered ${response.status}.` },
36+ );
37+ } catch {
38+ // Offline, or the page is leaving: the app asks again in a minute.
39+ } finally {
40+ asking = null;
41+ }
42+ };
43+ const stop = runner.onWanted((workspace) => void register(workspace));
44+ void runner
45+ .offer(JSON.parse(key) as DesktopRunnerOffer)
46+ .then((wanted) => {
47+ if (wanted) void register(wanted);
48+ })
49+ .catch(() => {});
50+ return stop;
51+ }, [key]);
52+
53+ // Reporting: what the Runners page shows of this computer, on each change and as a heartbeat.
54+ useEffect(() => {
55+ const runner = desktopRunner();
56+ if (!runner || !offer.signedIn) return;
57+ const send = (report: DesktopReport | null) => {
58+ if (!report || !offer.workspaces.some((w) => w.slug === report.workspace)) return;
59+ const now = Date.now();
60+ if (!reportDue(last.current, report, now)) return;
61+ last.current = { key: reportKey(report), at: now };
62+ void fetch("/-/desktop/runner", {
63+ method: "POST",
64+ headers: { "content-type": "application/json" },
65+ body: JSON.stringify({ intent: "report", ...report }),
66+ }).catch(() => {
67+ last.current = null;
68+ });
69+ };
70+ const look = () =>
71+ void runner
72+ .status()
73+ .then((view) => send(reportOf(view)))
74+ .catch(() => {});
75+ look();
76+ const stop = runner.onStatus((view) => send(reportOf(view)));
77+ const beat = window.setInterval(look, 60_000);
78+ return () => {
79+ stop();
80+ window.clearInterval(beat);
81+ };
82+ }, [key]); // eslint-disable-line react-hooks/exhaustive-deps
83+ return null;
84+}
+121−0
1+import { Laptop } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { DesktopBackend, DesktopDiagnosis, DesktopRunner } from "@g1t/contracts";
5+
6+import { BACKEND_LABEL, type DesktopRunnerAction, desktopRunner, useDesktopRunner } from "../lib/desktop-runner";
7+import { EmptyState, TimeAgo } from "./ui";
8+import { Badge } from "./ui/badge";
9+import { Button } from "./ui/button";
10+import { Card } from "./ui/card";
11+
12+/** A diagnosis's tone, as a dot. */
13+const DOT: Record<DesktopDiagnosis["tone"], string> = {
14+ ok: "bg-success",
15+ working: "bg-info",
16+ off: "bg-faint",
17+ warning: "bg-warn",
18+ problem: "bg-danger",
19+};
20+
21+type Row = {
22+ machine: string;
23+ person: string | null;
24+ backend: DesktopBackend;
25+ version: string | null;
26+ diagnosis: DesktopDiagnosis;
27+ /** This computer's fixes, which only work here. */
28+ actions: DesktopRunnerAction[];
29+ here: boolean;
30+ reportedAt: string | null;
31+};
32+
33+/** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */
34+export function desktopRows(desktops: DesktopRunner[], here: ReturnType<typeof useDesktopRunner>, slug: string): Row[] {
35+ const rows: Row[] = [];
36+ if (here && here.workspace === slug) {
37+ rows.push({
38+ machine: here.machine,
39+ person: null,
40+ backend: here.backend,
41+ version: here.version,
42+ diagnosis: here.diagnosis,
43+ actions: here.diagnosis.actions,
44+ here: true,
45+ reportedAt: null,
46+ });
47+ }
48+ for (const desktop of desktops) {
49+ if (rows.some((row) => row.here && row.machine.toLowerCase() === desktop.machine.toLowerCase())) continue;
50+ rows.push({
51+ machine: desktop.machine,
52+ person: desktop.person,
53+ backend: desktop.backend,
54+ version: desktop.version,
55+ diagnosis: desktop.open ? desktop.diagnosis : desktop.closed,
56+ actions: [],
57+ here: false,
58+ reportedAt: desktop.reportedAt,
59+ });
60+ }
61+ return rows;
62+}
63+
64+/**
65+ * The workspace's desktops: each person's computer with the g1t desktop
66+ * app, whose runner runs while g1t is open there. What each says, why it
67+ * isn't running and the fix, is the app's own words (lib/desktop-runner.ts).
68+ */
69+export function DesktopRunners({ desktops, slug }: { desktops: DesktopRunner[] | null; slug: string }) {
70+ const here = useDesktopRunner();
71+ if (!desktops) return <p className="text-sm text-muted">The desktops can&apos;t be read right now.</p>;
72+ const rows = desktopRows(desktops, here, slug);
73+ if (rows.length === 0) {
74+ return (
75+ <EmptyState title="No desktops yet">
76+ The <Link to="/download" className="underline underline-offset-2">g1t desktop app</Link> runs this workspace&apos;s self-hosted jobs while it&apos;s open, sandboxed, with nothing to set up.
77+ Open it signed in as an owner of {slug}.
78+ </EmptyState>
79+ );
80+ }
81+ return (
82+ <Card asChild divided>
83+ <ul>
84+ {rows.map((row) => (
85+ <li key={row.machine} className="flex items-start gap-3 px-4 py-3">
86+ <span className={`mt-1.5 size-2 shrink-0 rounded-full ${DOT[row.diagnosis.tone]}`} aria-hidden />
87+ <div className="min-w-0 grow">
88+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
89+ <Laptop size={13} className="text-muted" aria-hidden />
90+ <span className="font-medium">{row.machine}</span>
91+ {row.here && <Badge tone="accent">This computer</Badge>}
92+ <Badge tone={row.backend === "sandboxed" ? "neutral" : "warn"}>{BACKEND_LABEL[row.backend]}</Badge>
93+ {row.person && <span className="text-xs text-faint">@{row.person}</span>}
94+ </div>
95+ <p className="mt-1 text-sm text-fg">{row.diagnosis.title}</p>
96+ {row.diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{row.diagnosis.cause}</p>}
97+ {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>}
98+ {row.actions.length > 0 && (
99+ <div className="mt-2 flex flex-wrap gap-2">
100+ {row.actions.map((action, at) => (
101+ <Button key={action.id} size="xs" variant={at === 0 ? "secondary" : "outline"} onClick={() => void desktopRunner()?.act(action.id).catch(() => {})}>
102+ {action.label}
103+ </Button>
104+ ))}
105+ </div>
106+ )}
107+ </div>
108+ <div className="shrink-0 text-right text-xs text-faint">
109+ {row.version && <div>g1t-runner {row.version}</div>}
110+ {row.reportedAt && (
111+ <div className="mt-0.5">
112+ Heard from <TimeAgo at={row.reportedAt} />
113+ </div>
114+ )}
115+ </div>
116+ </li>
117+ ))}
118+ </ul>
119+ </Card>
120+ );
121+}
+14−5
77 import { money } from "../lib/money";
88 import { type CloudWork, machineTime, perMinute } from "../lib/runners";
99 import type { RunnersAction, RunnersPageData } from "../lib/runners.server";
10+import { DesktopRunners } from "./desktop-runners";
1011 import { Groups, NewRunner, RunnerRow, RunnerSettingsForm, STATUS } from "./runners";
1112 import { EmptyState, ErrorText, TimeAgo } from "./ui";
1213 import { Badge } from "./ui/badge";
303304 )}
304305 </Section>
305306
307+ <Section
308+ id="desktops"
309+ title="Desktops"
310+ about="Computers with the g1t desktop app, whose runner runs while g1t is open there: what each is doing, and when it isn't running, why and the fix."
311+ >
312+ <DesktopRunners desktops={data.desktops} slug={slug} />
313+ </Section>
314+
306315 <Section title="Where work runs" about="For every project in the workspace, unless a project says otherwise in its own settings.">
307316 <div className="grid gap-3 md:grid-cols-3">
308317 <WhereCard icon={<Workflow size={15} />} title="Workflow jobs">
315324 ? `Agent runs, checks, reviews and the merge queue run on your runners labelled ${data.settings.agentLabels.join(", ")}.`
316325 : "Agent runs, checks, reviews and the merge queue run on g1t cloud. Switch them to your runners below."}
317326 </WhereCard>
318− <WhereCard icon={<Laptop size={15} />} title="Your desktop" coming>
319− The desktop app will include a runner: for a logged-in browser, a simulator or a tool only your network reaches. It asks before each task.
327+ <WhereCard icon={<Laptop size={15} />} title="Your desktop">
328+ The g1t desktop app runs a runner while it&apos;s open, sandboxed and labelled <code className="font-mono text-fg-soft">desktop</code>, for jobs that ask for{" "}
329+ <code className="font-mono text-fg-soft">self-hosted</code>. Each desktop is under <a href="#desktops" className="underline underline-offset-2">Desktops</a>.
320330 </WhereCard>
321331 </div>
322332 <RunnerSettingsForm
354364 );
355365 }
356366
357−function WhereCard({ icon, title, coming, children }: { icon: ReactNode; title: string; coming?: boolean; children: ReactNode }) {
367+function WhereCard({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
358368 return (
359− <div className={`rounded-2xl border p-4 ${coming ? "border-dashed border-line" : "border-line bg-surface"}`}>
369+ <div className="rounded-2xl border border-line bg-surface p-4">
360370 <h3 className="flex items-center gap-2 text-sm font-medium">
361371 <span className="text-muted">{icon}</span>
362372 {title}
363− {coming && <span className="ml-auto"><Coming /></span>}
364373 </h3>
365374 <p className="mt-2 text-sm leading-relaxed text-muted">{children}</p>
366375 </div>
+5−2
22 import { useEffect, useState } from "react";
33 import { Form, Link } from "react-router";
44
5−import { type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts";
5+import { DESKTOP_LABEL, type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts";
66
77 import { agentRunHref, workflowRunHref } from "../lib/runners";
88 import type { RunnersAction, RunnersData } from "../lib/runners.server";
5555 <div className="min-w-0 grow basis-60">
5656 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
5757 <span className="font-medium">{runner.name}</span>
58− <span className={`text-xs font-medium ${status.text}`}>{status.label}</span>
58+ <span className={`text-xs font-medium ${status.text}`}>
59+ {/* A desktop app's runner runs while g1t is open there: offline is g1t closed. */}
60+ {runner.status === "offline" && runner.labels.includes(DESKTOP_LABEL) ? `g1t is closed on ${runner.name}` : status.label}
61+ </span>
5962 {runner.ephemeral && <Badge size="md">ephemeral</Badge>}
6063 {runner.group && <span className="text-xs text-faint">in {runner.group}</span>}
6164 </div>
+61−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { HEARTBEAT_MS, type DesktopRunnerView, backendOf, diagnosisOf, reportDue, reportKey, reportOf } from "./desktop-runner.ts";
5+
6+function view(over: Partial<DesktopRunnerView> = {}): DesktopRunnerView {
7+ return {
8+ diagnosis: { code: "no_docker", tone: "problem", title: "Not running: Docker isn't running", cause: "Docker isn't running.", fix: "Start Docker Desktop.", actions: [{ id: "open_docker", label: "Open Docker" }] },
9+ closed: { code: "off", tone: "off", title: "g1t is closed on build-box", cause: null, fix: "Open g1t on build-box.", actions: [] },
10+ running: false,
11+ version: "0.2.3",
12+ backend: "docker",
13+ workspace: "acme",
14+ workspaces: [{ slug: "acme", name: "Acme" }],
15+ enabled: true,
16+ paused: false,
17+ link: "https://g1t.sh/acme/-/runners",
18+ machine: "build-box",
19+ platform: "windows",
20+ log: ["2026-10-12T08:00:00Z a line"],
21+ ...over,
22+ };
23+}
24+
25+test("a report carries the app's words, without its buttons or log", () => {
26+ const report = reportOf(view());
27+ assert.deepEqual(report, {
28+ workspace: "acme",
29+ machine: "build-box",
30+ backend: "docker",
31+ version: "0.2.3",
32+ diagnosis: { code: "no_docker", tone: "problem", title: "Not running: Docker isn't running", cause: "Docker isn't running.", fix: "Start Docker Desktop." },
33+ closed: { code: "off", tone: "off", title: "g1t is closed on build-box", cause: null, fix: "Open g1t on build-box." },
34+ });
35+ assert.equal(reportOf(view({ workspace: null })), null);
36+});
37+
38+test("a report goes when what the page shows changes, and as a heartbeat", () => {
39+ const report = reportOf(view())!;
40+ const at = 1_000_000;
41+ assert.equal(reportDue(null, report, at), true);
42+ const last = { key: reportKey(report), at };
43+ assert.equal(reportDue(last, report, at + 1000), false);
44+ assert.equal(reportDue(last, report, at + HEARTBEAT_MS), true);
45+ const busy = reportOf(view({ diagnosis: { ...view().diagnosis, code: "busy", tone: "working", title: "Running Build (acme/web)" } }))!;
46+ assert.equal(reportDue(last, busy, at + 1000), true);
47+});
48+
49+test("only a diagnosis and a backend the app makes are kept", () => {
50+ assert.deepEqual(diagnosisOf({ code: "idle", tone: "ok", title: "Waiting for jobs", cause: "Running sandboxed.", extra: 1 }), {
51+ code: "idle",
52+ tone: "ok",
53+ title: "Waiting for jobs",
54+ cause: "Running sandboxed.",
55+ fix: null,
56+ });
57+ assert.equal(diagnosisOf({ code: "idle", tone: "loud", title: "x" }), null);
58+ assert.equal(diagnosisOf("idle"), null);
59+ assert.equal(backendOf("host"), "host");
60+ assert.equal(backendOf("vm"), null);
61+});
+131−0
1+/**
2+ * The runner the desktop app keeps on this computer, from the site's side
3+ * (apps/desktop/src-tauri/src/runner). The app finds out what to run for,
4+ * and gets its registration token, through the page:
5+ *
6+ * 1. On every page, the page tells the app who is signed in, the
7+ * workspaces they own (only owners add runners) and the one they are
8+ * in (`offer`). The app answers with the workspace it wants a
9+ * registration token for, if any, and asks again later (`onWanted`).
10+ * 2. For that workspace, the page asks the server for a token
11+ * (`POST /-/desktop/runner`, owners only, same origin) and hands it to
12+ * the app (`register`), or says why there is none. It is never shown
13+ * or kept here.
14+ * 3. Whenever the runner's state changes, and every few minutes while g1t
15+ * is open, the page sends the app's diagnosis to the server
16+ * (`report`), so the workspace's Runners page lists every person's
17+ * desktop with its state, why and the fix, in the app's own words.
18+ */
19+import { useEffect, useState } from "react";
20+
21+import type { DesktopBackend, DesktopDiagnosis, DesktopReport } from "@g1t/contracts";
22+
23+/** A fix, as a button: its id is the app's (`runner_control`). */
24+export type DesktopRunnerAction = { id: string; label: string };
25+
26+/** What the app says of its runner (`runner::View` in the app). */
27+export type DesktopRunnerView = {
28+ diagnosis: DesktopDiagnosis & { actions: DesktopRunnerAction[] };
29+ closed: DesktopDiagnosis & { actions: DesktopRunnerAction[] };
30+ running: boolean;
31+ version: string | null;
32+ backend: DesktopBackend;
33+ workspace: string | null;
34+ workspaces: { slug: string; name: string | null }[];
35+ enabled: boolean;
36+ paused: boolean;
37+ link: string | null;
38+ machine: string;
39+ platform: string;
40+ log: string[];
41+};
42+
43+export type DesktopRunnerOffer = { signedIn: boolean; workspaces: { slug: string; name: string | null }[]; current: string | null };
44+
45+/** `window.g1tDesktop.runner`, from the app's bridge.js. */
46+export type DesktopRunnerBridge = {
47+ status(): Promise<DesktopRunnerView>;
48+ offer(offer: DesktopRunnerOffer): Promise<string | null>;
49+ register(answer: { workspace: string; token?: string | null; allowed?: boolean; error?: string | null }): Promise<void>;
50+ act(action: string): Promise<void>;
51+ onWanted(listener: (workspace: string) => void): () => void;
52+ onStatus(listener: (view: DesktopRunnerView) => void): () => void;
53+};
54+
55+/** The bridge's runner, inside the desktop app (lib/notify-client.ts describes the rest of `window.g1tDesktop`). */
56+export function desktopRunner(): DesktopRunnerBridge | null {
57+ if (typeof window === "undefined") return null;
58+ const runner = window.g1tDesktop?.runner;
59+ return runner && typeof runner.status === "function" ? runner : null;
60+}
61+
62+/** This computer's runner, live, inside the desktop app; null elsewhere. */
63+export function useDesktopRunner(): DesktopRunnerView | null {
64+ const [view, setView] = useState<DesktopRunnerView | null>(null);
65+ useEffect(() => {
66+ const runner = desktopRunner();
67+ if (!runner) return;
68+ let live = true;
69+ runner
70+ .status()
71+ .then((first) => {
72+ if (live) setView(first);
73+ })
74+ .catch(() => {});
75+ const stop = runner.onStatus((next) => setView(next));
76+ return () => {
77+ live = false;
78+ stop();
79+ };
80+ }, []);
81+ return view;
82+}
83+
84+/** How often a desktop says it is still open, with nothing new: within the server's ten minutes. */
85+export const HEARTBEAT_MS = 4 * 60 * 1000;
86+
87+/** What goes to the server, without the buttons, which only work on that computer. */
88+export function reportOf(view: DesktopRunnerView): DesktopReport | null {
89+ if (!view.workspace) return null;
90+ const plain = ({ code, tone, title, cause, fix }: DesktopDiagnosis): DesktopDiagnosis => ({ code, tone, title, cause, fix });
91+ return { workspace: view.workspace, machine: view.machine, backend: view.backend, version: view.version, diagnosis: plain(view.diagnosis), closed: plain(view.closed) };
92+}
93+
94+/** Whether a report is due: something the Runners page shows changed, or the heartbeat. */
95+export function reportDue(last: { key: string; at: number } | null, report: DesktopReport, now: number): boolean {
96+ if (!last) return true;
97+ return last.key !== reportKey(report) || now - last.at >= HEARTBEAT_MS;
98+}
99+
100+export function reportKey(report: DesktopReport): string {
101+ const { diagnosis } = report;
102+ return [report.workspace, report.machine, report.backend, report.version, diagnosis.code, diagnosis.title, diagnosis.cause, diagnosis.fix].join("\u0000");
103+}
104+
105+/** A string with something in it, or null. */
106+export const text = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null);
107+
108+const BACKENDS: DesktopBackend[] = ["sandboxed", "docker", "host"];
109+const TONES: DesktopDiagnosis["tone"][] = ["ok", "working", "off", "warning", "problem"];
110+
111+export function backendOf(value: unknown): DesktopBackend | null {
112+ return BACKENDS.find((backend) => backend === value) ?? null;
113+}
114+
115+/** A diagnosis as the app sent it, or null when it is not one. */
116+export function diagnosisOf(value: unknown): DesktopDiagnosis | null {
117+ if (!value || typeof value !== "object") return null;
118+ const given = value as Record<string, unknown>;
119+ const code = text(given.code);
120+ const title = text(given.title);
121+ const tone = TONES.find((t) => t === given.tone);
122+ if (!code || !title || !tone) return null;
123+ return { code, tone, title, cause: text(given.cause), fix: text(given.fix) };
124+}
125+
126+/** The words for how a desktop runs jobs, as its settings say them. */
127+export const BACKEND_LABEL: Record<DesktopBackend, string> = {
128+ sandboxed: "Sandboxed",
129+ docker: "In Docker (not a sandbox)",
130+ host: "Directly on the computer (not a sandbox)",
131+};
+6−0
2525
2626 import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry, RepoChange } from "@g1t/contracts";
2727
28+import type { DesktopRunnerBridge } from "./desktop-runner";
2829 import { openLive } from "./live-socket";
2930 import { dndOn, isIdle, mergePeople } from "./presence";
3031 import { notificationArrived } from "./sound-events";
8182 * // How tall the app's own title bar is, 0 under the system's. The bar
8283 * // is a web view of its own, above the page, never over it.
8384 * titleBarHeight: 36,
85+ * // The runner the app keeps on this computer: its status and
86+ * // diagnosis, who to register for, its token, and its fixes
87+ * // (lib/desktop-runner.ts has the details).
88+ * runner: { status, offer, register, act, onWanted, onStatus },
8489 * };
8590 *
8691 * The app also opens the site's pages for reading about g1t (pricing,
100105 openSettings?(): void;
101106 setSearch?(label: string, open: () => void): () => void;
102107 titleBarHeight?: number;
108+ runner?: DesktopRunnerBridge;
103109 };
104110
105111 declare global {
+9−1
11 import { env } from "cloudflare:workers";
22
33 import {
4+ type DesktopRunner,
45 type RegistrationToken,
56 type Runner,
67 type RunnerGroup,
5556 prices: CloudPrice[] | null;
5657 /** Agent runs at once on g1t's cloud the plan allows; null for no cap, or unknown. */
5758 agentCap: number | null;
59+ /** People's computers with the desktop app, as each app last said; null when the actions service did not answer. */
60+ desktops: DesktopRunner[] | null;
5861 };
5962
6063 /** Active agent runs read: the work service's most in one list. */
7477 export async function loadRunnersPage(workspace: string, actor: User, repositories: string[] = [], now = new Date()): Promise<RunnersPageData> {
7578 const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10);
7679 const until = now.toISOString().slice(0, 10);
77− const [base, activity, runs, report, book, entitlements] = await Promise.all([
80+ const [base, activity, runs, report, book, entitlements, desktops] = await Promise.all([
7881 loadRunners({ workspace }, actor, repositories),
7982 runners
8083 .activity(actor, workspace)
9093 .catch(warn("usage report")),
9194 billing.prices().catch(warn("prices")),
9295 billing.entitlements(workspace).catch(warn("entitlements")),
96+ runners
97+ .desktops(actor, workspace)
98+ .then((result) => (result.ok ? result.value : null))
99+ .catch(warn("desktops")),
93100 ]);
94101 return {
95102 ...base,
98105 cost: runnerCost(report),
99106 prices: cloudPrices(book?.prices ?? null),
100107 agentCap: entitlements && entitlements.maxConcurrentAgents > 0 ? entitlements.maxConcurrentAgents : null,
108+ desktops,
101109 };
102110 }
103111
+11−0
5353 import { isNeedsSignIn } from "./lib/website-token";
5454 import { offerTicket, setLiveViaToken } from "./lib/live-socket";
5555 import { socketTicketFor } from "./lib/socket-ticket.server";
56+import { DesktopRunnerLink } from "./components/desktop-runner-link";
5657 import { LiveNotifications } from "./components/notifications/live-notifications";
5758 import { THEME_COLOR, THEME_COOKIE, readTheme, themeAttribute, useThemeChoice } from "./lib/theme";
5859
461462 me={{ id: user.id, username: user.username }}
462463 />
463464 )}
465+ {/* Inside the desktop app: who its runner runs for, and its registration (lib/desktop-runner.ts). */}
466+ {(!user || !awaitsConfirmation(user)) && (
467+ <DesktopRunnerLink
468+ offer={{
469+ signedIn: user != null,
470+ workspaces: (user?.workspaces ?? []).filter((m) => m.role === "owner").map((m) => ({ slug: m.slug, name: m.name ?? null })),
471+ current: root?.shell?.workspace?.slug ?? null,
472+ }}
473+ />
474+ )}
464475 <AnalyticsConsent />
465476 <ScrollRestoration nonce={nonce} />
466477 <Scripts nonce={nonce} />
+2−0
7575 route("invitations", "routes/invitations.tsx"),
7676 // People to invite, as the People page's invite form searches them.
7777 route("-/people.json", "routes/people-json.ts"),
78+ // The desktop app's runner: its registration token, and its reports (lib/desktop-runner.ts).
79+ route("-/desktop/runner", "routes/desktop-runner.ts"),
7880 // Trust pages: reserved names, like the rest above.
7981 route("policies", "routes/policies.tsx"),
8082 route("policies/:policy", "routes/policy.tsx"),
+52−0
1+/**
2+ * The desktop app's runner, from its page (lib/desktop-runner.ts): a
3+ * registration token for a workspace the person owns, which the page
4+ * hands to the app and never shows, and the app's reports of how its
5+ * runner is, kept for the workspace's Runners page.
6+ *
7+ * Signed in, same origin, and owners of the workspace only, as the
8+ * Runners page's own New runner is; the actions service checks again.
9+ * `-` is no workspace's name, so nothing else is ever found here.
10+ *
11+ * POST { "intent": "token", "workspace": "acme" }
12+ * → { "token": "g1trt_…", "workspace": "acme", "url": "https://g1t.sh", "expires_at": "…" }
13+ * → 403 { "error": "…", "allowed": false }
14+ * POST { "intent": "report", "workspace", "machine", "backend", "version", "diagnosis", "closed" }
15+ * → { "ok": true }
16+ */
17+import type { Route } from "./+types/desktop-runner";
18+import { backendOf, diagnosisOf, text } from "../lib/desktop-runner";
19+import { runners } from "../lib/runners.server";
20+import { assertSameOrigin, getViewer, roleIn } from "../lib/session.server";
21+
22+export async function action({ request, context }: Route.ActionArgs) {
23+ assertSameOrigin(request);
24+ const headers = { "cache-control": "no-store" };
25+ const viewer = getViewer(context);
26+ if (!viewer || (viewer.kind ?? "user") !== "user") return Response.json({ error: "Sign in to g1t." }, { status: 401, headers });
27+ const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
28+ const workspace = text(body?.workspace)?.toLowerCase() ?? null;
29+ if (!body || !workspace) return Response.json({ error: "Name a workspace." }, { status: 400, headers });
30+ if (roleIn(viewer, workspace) !== "owner") {
31+ return Response.json({ error: `You can't add runners to ${workspace}: only its owners can.`, allowed: false }, { status: 403, headers });
32+ }
33+ switch (body.intent) {
34+ case "token": {
35+ const made = await runners.createToken(viewer, { workspace });
36+ if (!made.ok) return Response.json({ error: made.error.message, allowed: true }, { status: 409, headers });
37+ const token = made.value;
38+ return Response.json({ token: token.token, workspace: token.workspace, url: token.url, expires_at: token.expiresAt }, { headers });
39+ }
40+ case "report": {
41+ const backend = backendOf(body.backend);
42+ const machine = text(body.machine);
43+ const diagnosis = diagnosisOf(body.diagnosis);
44+ const closed = diagnosisOf(body.closed);
45+ if (!backend || !machine || !diagnosis || !closed) return Response.json({ error: "That isn't a desktop's report." }, { status: 400, headers });
46+ const kept = await runners.reportDesktop(viewer, { workspace, machine, backend, version: text(body.version), diagnosis, closed });
47+ return kept.ok ? Response.json({ ok: true }, { headers }) : Response.json({ error: kept.error.message }, { status: 409, headers });
48+ }
49+ default:
50+ return Response.json({ error: "Nothing to do." }, { status: 400, headers });
51+ }
52+}
+68−0
230230 pub id: String,
231231 }
232232
233+// --- The desktop app's runners ----------------------------------------------
234+
235+/// The desktop app keeps a runner on a person's computer while it is open
236+/// (apps/desktop/src-tauri/src/runner). What it says of that runner, as it
237+/// says it in its tray and settings (its diagnosis.rs, the one catalogue
238+/// of those words), is kept here per computer, so the workspace's Runners
239+/// page lists every desktop with the same state, why, and the fix. A
240+/// desktop not heard from for `DESKTOP_OPEN_WITHIN_MS` has g1t closed, and
241+/// shows `closed`, which the app sent too.
242+pub const DESKTOP_OPEN_WITHIN_MS: u64 = 10 * 60 * 1000;
243+/// The most desktops a workspace keeps reports for.
244+pub const MAX_DESKTOPS: u32 = 200;
245+
246+/// A diagnosis, as the app made it.
247+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
248+pub struct DesktopDiagnosis {
249+ /// `idle`, `busy`, `no_docker`, `no_sandbox`, `not_allowed`, …
250+ pub code: String,
251+ /// `ok`, `working`, `off`, `warning` or `problem`.
252+ pub tone: String,
253+ pub title: String,
254+ #[serde(default)]
255+ pub cause: Option<String>,
256+ #[serde(default)]
257+ pub fix: Option<String>,
258+}
259+
260+/// One person's computer, as its desktop app last said.
261+#[derive(Clone, Debug, Serialize, Deserialize)]
262+#[serde(rename_all = "camelCase")]
263+pub struct DesktopRunner {
264+ pub workspace: String,
265+ /// The computer's name, which its runner has too.
266+ pub machine: String,
267+ /// Whose computer: their username.
268+ pub person: String,
269+ /// `sandboxed`, `docker` or `host`.
270+ pub backend: String,
271+ pub version: Option<String>,
272+ pub diagnosis: DesktopDiagnosis,
273+ /// What to show while g1t is closed on it.
274+ pub closed: DesktopDiagnosis,
275+ /// Heard from within `DESKTOP_OPEN_WITHIN_MS`: g1t is open there.
276+ pub open: bool,
277+ pub reported_at: String,
278+}
279+
280+/// `report_desktop`: the person's own desktop app, through the site, for a
281+/// workspace they own. Returns `Outcome<bool>`.
282+#[derive(Debug, Serialize, Deserialize)]
283+pub struct ReportDesktopArgs {
284+ pub actor: User,
285+ pub workspace: String,
286+ pub machine: String,
287+ pub backend: String,
288+ #[serde(default)]
289+ pub version: Option<String>,
290+ pub diagnosis: DesktopDiagnosis,
291+ pub closed: DesktopDiagnosis,
292+}
293+
294+/// `desktop_runners`: owners of the workspace. Returns `Outcome<Vec<DesktopRunner>>`.
295+#[derive(Debug, Serialize, Deserialize)]
296+pub struct DesktopRunnersArgs {
297+ pub actor: User,
298+ pub workspace: String,
299+}
300+
233301 /// `runner_settings`: members of the workspace, or admins of the
234302 /// repository. Returns `Outcome<RunnerSettings>`.
235303 #[derive(Debug, Serialize, Deserialize)]
+2−0
867867 deleteGroup: (actor, workspace, id) => call("delete_runner_group", { actor, workspace, id }),
868868 settings: (actor, owner) => call("runner_settings", { actor, ...owner }),
869869 setSettings: (actor, owner, change) => call("set_runner_settings", { actor, ...owner, ...change }),
870+ reportDesktop: (actor, report) => call("report_desktop", { actor, ...report }),
871+ desktops: (actor, workspace) => call("desktop_runners", { actor, workspace }),
870872 };
871873 }
872874
+49−0
123123 deleteGroup(actor: User, workspace: string, id: string): Promise<Result<boolean>>;
124124 settings(actor: User, owner: RunnersOwner): Promise<Result<RunnerSettings>>;
125125 setSettings(actor: User, owner: RunnersOwner, change: RunnerSettingsChange): Promise<Result<RunnerSettings>>;
126+ /** What the person's own desktop app says of its runner, in a workspace they own. */
127+ reportDesktop(actor: User, report: DesktopReport): Promise<Result<boolean>>;
128+ /** The workspace's desktops, as each app last said. Owners. */
129+ desktops(actor: User, workspace: string): Promise<Result<DesktopRunner[]>>;
126130 }
127131
128132 /**
133+ * The desktop app keeps a runner on a person's computer while it is open.
134+ * What it says of that runner (its state, why, and the fix) comes from one
135+ * catalogue in the app (apps/desktop/src-tauri/src/runner/diagnosis.rs),
136+ * and the site shows it word for word: `closed` is what to show once g1t
137+ * is closed on that computer, which the app sends too.
138+ */
139+export type DesktopDiagnosis = {
140+ code: string;
141+ tone: "ok" | "working" | "off" | "warning" | "problem";
142+ title: string;
143+ cause: string | null;
144+ fix: string | null;
145+};
146+
147+/** How a desktop runs jobs. */
148+export type DesktopBackend = "sandboxed" | "docker" | "host";
149+
150+export type DesktopReport = {
151+ workspace: string;
152+ machine: string;
153+ backend: DesktopBackend;
154+ version: string | null;
155+ diagnosis: DesktopDiagnosis;
156+ closed: DesktopDiagnosis;
157+};
158+
159+/** One person's computer, as its desktop app last said. */
160+export type DesktopRunner = {
161+ workspace: string;
162+ machine: string;
163+ /** Whose computer: their username. */
164+ person: string;
165+ backend: DesktopBackend;
166+ version: string | null;
167+ diagnosis: DesktopDiagnosis;
168+ closed: DesktopDiagnosis;
169+ /** Heard from in the last ten minutes: g1t is open there. */
170+ open: boolean;
171+ reportedAt: string;
172+};
173+
174+/** The label every desktop app's runner has. */
175+export const DESKTOP_LABEL = "desktop";
176+
177+/**
129178 * The runner's container image: `g1t-runner` and the Docker CLI, for
130179 * running it in Docker or Kubernetes. Published with each release.
131180 */
+15−0
1+-- What the desktop app last said of the runner it keeps on a person's
2+-- computer: its diagnosis (state, why, the fix) and what to show once g1t
3+-- is closed there, as JSON in the app's own words. One row per computer
4+-- in a workspace, replaced at each report.
5+CREATE TABLE desktop_runners (
6+ workspace TEXT NOT NULL,
7+ machine TEXT NOT NULL,
8+ person TEXT NOT NULL,
9+ backend TEXT NOT NULL,
10+ version TEXT,
11+ diagnosis TEXT NOT NULL,
12+ closed TEXT NOT NULL,
13+ reported_at TEXT NOT NULL,
14+ PRIMARY KEY (workspace, machine)
15+);
+3−0
307307 "delete_runner_group" => reply(&service.delete_runner_group(args(body)?).await?),
308308 "runner_settings" => reply(&service.runner_settings(args(body)?).await?),
309309 "set_runner_settings" => reply(&service.set_runner_settings(args(body)?).await?),
310+ // The desktop app's runners, as each app says they are.
311+ "report_desktop" => reply(&service.report_desktop(args(body)?).await?),
312+ "desktop_runners" => reply(&service.desktop_runners(args(body)?).await?),
310313 // The runner's own side, through the API with its credential.
311314 "runner_register" => reply(&service.runner_register(args(body)?).await?),
312315 "runner_poll" => reply(&service.runner_poll(args(body)?).await?),
+103−2
1515 use g1t_contracts::billing::{ComputeKind, RecordSandboxArgs};
1616 use g1t_contracts::repos::{Repo, RepoPath};
1717 use g1t_contracts::runners::{
18− self as model, ACTIVITY_LIMIT, CloudJob, HandedOverTask, RunnerActivity, RunnerActivityArgs, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs,
18+ self as model, ACTIVITY_LIMIT, CloudJob, DesktopDiagnosis, DesktopRunner, DesktopRunnersArgs, ReportDesktopArgs, HandedOverTask, RunnerActivity, RunnerActivityArgs, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs,
1919 ListRunnersArgs, Poll, PollArgs, RegisterArgs, Registered, RegistrationToken, RemoveRunnerArgs, RemoveSelfArgs, RouteArgs, Runner,
2020 RunnerAuth, RunnerGroup, RunnerGroupsArgs, RunnerSettings, RunnerSettingsArgs, RunnerWork, RunnersOwner, SetRunnerGroupArgs,
2121 SetRunnerSettingsArgs, Wanted,
123123 repo: Option<String>,
124124 group_id: Option<String>,
125125 expires_at: String,
126+ /// Who made the token: the runners it registers are theirs.
127+ #[serde(default)]
128+ created_by: Option<String>,
126129 }
127130
128131 #[derive(Clone, Debug, Deserialize)]
703706 Ok(Outcome::Ok(self.effective_runner_settings(&place.workspace, place.repo.as_ref().map(|r| r.id.as_str())).await?))
704707 }
705708
709+ /// `report_desktop`: what a person's desktop app says of its runner,
710+ /// in a workspace they own. Kept per computer, replaced each time.
711+ pub async fn report_desktop(&self, a: ReportDesktopArgs) -> Result<Outcome<bool>> {
712+ let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
713+ let place = check!(self.runner_place(&a.actor, &owner, true).await?);
714+ let machine = match valid_name(&a.machine) {
715+ Ok(name) => name,
716+ Err(problem) => return Ok(fail(FailureCode::Invalid, problem)),
717+ };
718+ if !matches!(a.backend.as_str(), "sandboxed" | "docker" | "host") {
719+ return Ok(fail(FailureCode::Invalid, "A desktop runs jobs sandboxed, in Docker or on the host."));
720+ }
721+ let clip = |text: &str, most: usize| text.chars().take(most).collect::<String>();
722+ let tidy = |d: &DesktopDiagnosis| DesktopDiagnosis {
723+ code: clip(&d.code, 40),
724+ tone: clip(&d.tone, 20),
725+ title: clip(&d.title, 200),
726+ cause: d.cause.as_deref().map(|t| clip(t, 1000)),
727+ fix: d.fix.as_deref().map(|t| clip(t, 1000)),
728+ };
729+ let at = now_ms();
730+ let known = self
731+ .db
732+ .prepare("SELECT COUNT(*) AS n FROM desktop_runners WHERE workspace = ? AND lower(machine) <> lower(?)")
733+ .bind(&[place.workspace.as_str().into(), machine.as_str().into()])?
734+ .first::<Count>(None)
735+ .await?
736+ .map_or(0, |c| c.n);
737+ if known >= model::MAX_DESKTOPS {
738+ // The oldest report makes room: a computer long gone.
739+ self.db
740+ .prepare("DELETE FROM desktop_runners WHERE rowid IN (SELECT rowid FROM desktop_runners WHERE workspace = ? ORDER BY reported_at LIMIT 1)")
741+ .bind(&[place.workspace.as_str().into()])?
742+ .run()
743+ .await?;
744+ }
745+ self.db
746+ .prepare(
747+ "INSERT INTO desktop_runners (workspace, machine, person, backend, version, diagnosis, closed, reported_at)
748+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
749+ ON CONFLICT (workspace, machine) DO UPDATE SET person = ?3, backend = ?4, version = ?5, diagnosis = ?6, closed = ?7, reported_at = ?8",
750+ )
751+ .bind(&[
752+ place.workspace.as_str().into(),
753+ machine.as_str().into(),
754+ a.actor.username.as_str().into(),
755+ a.backend.as_str().into(),
756+ optional(a.version.as_deref().map(|v| clip(v, 40)).as_deref()),
757+ serde_json::to_string(&tidy(&a.diagnosis))?.into(),
758+ serde_json::to_string(&tidy(&a.closed))?.into(),
759+ rfc3339(at).into(),
760+ ])?
761+ .run()
762+ .await?;
763+ Ok(Outcome::Ok(true))
764+ }
765+
766+ /// `desktop_runners`: the workspace's desktops, as each app last said.
767+ pub async fn desktop_runners(&self, a: DesktopRunnersArgs) -> Result<Outcome<Vec<DesktopRunner>>> {
768+ let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
769+ let place = check!(self.runner_place(&a.actor, &owner, false).await?);
770+ #[derive(Deserialize)]
771+ struct Row {
772+ workspace: String,
773+ machine: String,
774+ person: String,
775+ backend: String,
776+ version: Option<String>,
777+ diagnosis: String,
778+ closed: String,
779+ reported_at: String,
780+ }
781+ let rows = self
782+ .db
783+ .prepare("SELECT * FROM desktop_runners WHERE workspace = ? ORDER BY lower(machine)")
784+ .bind(&[place.workspace.as_str().into()])?
785+ .all()
786+ .await?
787+ .results::<Row>()?;
788+ let open_since = rfc3339(now_ms().saturating_sub(model::DESKTOP_OPEN_WITHIN_MS));
789+ Ok(Outcome::Ok(
790+ rows.into_iter()
791+ .map(|row| DesktopRunner {
792+ open: row.reported_at.as_str() > open_since.as_str(),
793+ diagnosis: serde_json::from_str(&row.diagnosis).unwrap_or_default(),
794+ closed: serde_json::from_str(&row.closed).unwrap_or_default(),
795+ workspace: row.workspace,
796+ machine: row.machine,
797+ person: row.person,
798+ backend: row.backend,
799+ version: row.version,
800+ reported_at: row.reported_at,
801+ })
802+ .collect(),
803+ ))
804+ }
805+
706806 /// `set_runner_settings`.
707807 pub async fn set_runner_settings(&self, a: SetRunnerSettingsArgs) -> Result<Outcome<RunnerSettings>> {
708808 let place = check!(self.runner_place(&a.actor, &a.owner, true).await?);
866966 created.as_str().into(),
867967 created.as_str().into(),
868968 created.as_str().into(),
869− JsValue::NULL,
969+ // The person who made the token: a desktop's runner is theirs.
970+ optional(registration.created_by.as_deref()),
870971 optional(isolation),
871972 ])?
872973 .run()

Comments

Sign in to comment.