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.

330 linesCodeBlameRaw
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 */
19import { useEffect, useState } from "react";
20
21import type {
22 DesktopBackend,
23 DesktopCapability,
24 DesktopCapabilityState,
25 DesktopDiagnosis,
26 DesktopJob,
27 DesktopReport,
28 DesktopUpdate,
29 Runner,
30} from "@g1t/contracts";
31
32import { sandboxRunHref, workflowRunHref } from "./runners.ts";
33
34/**
35 * A fix, as a button: its id is the app's (`runner_control`). `waits`: its
36 * button stays busy until the runner's status that follows (lib/busy.ts);
37 * `confirm`: what the person is asked first. Both are the app's, from its
38 * one catalogue of the runner's words (src/runner/diagnosis.rs).
39 */
40export type DesktopRunnerAction = { id: string; label: string; waits?: boolean; confirm?: string | null };
41
42/** The runner in a few words, as the title bar's dot says it (`diagnosis::Chip`). */
43export type DesktopRunnerChip = { tone: DesktopDiagnosis["tone"]; label: string };
44
45/**
46 * A kind of job the sandbox runs, with its fixes' buttons
47 * (`diagnosis::CapabilityLine` in the app), and what only this computer
48 * knows: whether it can start and stop on its own, and how far its
49 * image has downloaded.
50 */
51export type DesktopRunnerCapability = DesktopCapability & {
52 actions: DesktopRunnerAction[];
53 available?: boolean;
54 can_toggle?: boolean;
55 progress?: number | null;
56 job?: (DesktopJob & { link?: string | null }) | null;
57};
58
59/**
60 * How the runner's updates stand on this computer (`diagnosis::UpdateLine`
61 * in the app): besides what is reported, when it last looked in the app's
62 * words, and its button that looks now.
63 */
64export type DesktopRunnerUpdate = DesktopUpdate & {
65 /** `Checked 12 min ago`. */
66 checked: string;
67 checking: boolean;
68 /** "Check for runner updates". */
69 check: DesktopRunnerAction;
70};
71
72/** What the app says of its runner (`runner::View` in the app). */
73export type DesktopRunnerView = {
74 diagnosis: Omit<DesktopDiagnosis, "capabilities" | "update"> & {
75 actions: DesktopRunnerAction[];
76 capabilities?: DesktopRunnerCapability[];
77 update?: DesktopRunnerUpdate | null;
78 };
79 closed: DesktopDiagnosis & { actions: DesktopRunnerAction[] };
80 running: boolean;
81 version: string | null;
82 backend: DesktopBackend;
83 workspace: string | null;
84 workspaces: { slug: string; name: string | null }[];
85 enabled: boolean;
86 paused: boolean;
87 link: string | null;
88 machine: string;
89 platform: string;
90 log: string[];
91 /** Absent from an app older than the dot. */
92 chip?: DesktopRunnerChip;
93};
94
95/**
96 * The whole runner's status, as far as a button waits on it: a press of
97 * Start, Stop, Retry or Check for runner updates is done when any of it
98 * changes (lib/busy.ts).
99 */
100export function runnerStatusKey(view: DesktopRunnerView): string {
101 const { diagnosis } = view;
102 const update = diagnosis.update;
103 return JSON.stringify([
104 diagnosis.code,
105 diagnosis.tone,
106 diagnosis.title,
107 diagnosis.cause,
108 view.chip?.label,
109 view.enabled,
110 view.paused,
111 update?.checking,
112 update?.checked_at,
113 update?.ready,
114 ]);
115}
116
117/** One kind of job's status, with the whole runner's, for its own buttons. */
118export function kindStatusKey(kind: DesktopRunnerCapability, view: DesktopRunnerView | null): string {
119 return JSON.stringify([view ? runnerStatusKey(view) : null, kind.state, kind.enabled, kind.tone, kind.title, kind.cause]);
120}
121
122export type DesktopRunnerOffer = { signedIn: boolean; workspaces: { slug: string; name: string | null }[]; current: string | null };
123
124/** `window.g1tDesktop.runner`, from the app's bridge.js. */
125export type DesktopRunnerBridge = {
126 status(): Promise<DesktopRunnerView>;
127 offer(offer: DesktopRunnerOffer): Promise<string | null>;
128 register(answer: { workspace: string; token?: string | null; allowed?: boolean; error?: string | null }): Promise<void>;
129 /** A fix, or Start and Stop: of one kind of job alone with its id as `capability`. */
130 act(action: string, capability?: string): Promise<void>;
131 onWanted(listener: (workspace: string) => void): () => void;
132 onStatus(listener: (view: DesktopRunnerView) => void): () => void;
133};
134
135/** The bridge's runner, inside the desktop app (lib/notify-client.ts describes the rest of `window.g1tDesktop`). */
136export function desktopRunner(): DesktopRunnerBridge | null {
137 if (typeof window === "undefined") return null;
138 const runner = window.g1tDesktop?.runner;
139 return runner && typeof runner.status === "function" ? runner : null;
140}
141
142/** This computer's runner, live, inside the desktop app; null elsewhere. */
143export function useDesktopRunner(): DesktopRunnerView | null {
144 const [view, setView] = useState<DesktopRunnerView | null>(null);
145 useEffect(() => {
146 const runner = desktopRunner();
147 if (!runner) return;
148 let live = true;
149 runner
150 .status()
151 .then((first) => {
152 if (live) setView(first);
153 })
154 .catch(() => {});
155 const stop = runner.onStatus((next) => setView(next));
156 return () => {
157 live = false;
158 stop();
159 };
160 }, []);
161 return view;
162}
163
164/** How often a desktop says it is still open, with nothing new: within the server's ten minutes. */
165export const HEARTBEAT_MS = 4 * 60 * 1000;
166
167/** A kind of job as the server keeps it: what it says, without what only works or changes on that computer. */
168function plainCapability(capability: DesktopCapability): DesktopCapability {
169 const { id, label, tone, title, cause, fix, state, headline, about, enabled, job, runner, isolation, version, update } = capability;
170 const said: DesktopCapability = { id, label, tone, title, cause: cause ?? null, fix: fix ?? null };
171 if (state) said.state = state;
172 if (headline) said.headline = headline;
173 if (about) said.about = about;
174 if (typeof enabled === "boolean") said.enabled = enabled;
175 if (job) said.job = { kind: job.kind, name: job.name, repo: job.repo };
176 if (runner) said.runner = runner;
177 if (isolation) said.isolation = isolation;
178 if (version) said.version = version;
179 if (update) said.update = update;
180 return said;
181}
182
183/** How the updates stand, as the server keeps it: without what only this computer's settings show. */
184function plainUpdate(update: DesktopUpdate | null | undefined): DesktopUpdate | null {
185 if (!update) return null;
186 return { checked_at: update.checked_at ?? null, ready: update.ready ?? null, note: update.note ?? null };
187}
188
189/** What goes to the server, without the buttons, which only work on that computer. */
190export function reportOf(view: DesktopRunnerView): DesktopReport | null {
191 if (!view.workspace) return null;
192 const plain = ({ code, tone, title, cause, fix, capabilities, in_capabilities, update }: DesktopDiagnosis): DesktopDiagnosis => {
193 const said: DesktopDiagnosis = { code, tone, title, cause, fix };
194 if (capabilities?.length) {
195 said.capabilities = capabilities.map(plainCapability);
196 if (in_capabilities) said.in_capabilities = true;
197 }
198 const updates = plainUpdate(update);
199 if (updates) said.update = updates;
200 return said;
201 };
202 return {
203 workspace: view.workspace,
204 machine: view.machine,
205 backend: view.backend,
206 version: view.version,
207 diagnosis: plain(view.diagnosis),
208 closed: plain(view.closed),
209 };
210}
211
212/** Whether a report is due: something the Runners page shows changed, or the heartbeat. */
213export function reportDue(last: { key: string; at: number } | null, report: DesktopReport, now: number): boolean {
214 if (!last) return true;
215 return last.key !== reportKey(report) || now - last.at >= HEARTBEAT_MS;
216}
217
218export function reportKey(report: DesktopReport): string {
219 const { diagnosis } = report;
220 const capabilities = (diagnosis.capabilities ?? []).map((c) =>
221 [c.id, c.tone, c.title, c.cause, c.fix, c.state, c.enabled, c.headline, c.job?.name, c.job?.repo, c.version, c.update].join("\u0001"),
222 );
223 const update = diagnosis.update ? [diagnosis.update.checked_at, diagnosis.update.ready, diagnosis.update.note].join("\u0001") : "";
224 return [
225 report.workspace,
226 report.machine,
227 report.backend,
228 report.version,
229 diagnosis.code,
230 diagnosis.title,
231 diagnosis.cause,
232 diagnosis.fix,
233 diagnosis.in_capabilities,
234 update,
235 ...capabilities,
236 ].join("\u0000");
237}
238
239/** A string with something in it, or null. */
240export const text = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null);
241
242const BACKENDS: DesktopBackend[] = ["sandboxed", "docker", "host"];
243const TONES: DesktopDiagnosis["tone"][] = ["ok", "working", "off", "warning", "problem"];
244const STATES: DesktopCapabilityState[] = ["busy", "idle", "starting", "downloading", "updating", "waiting", "stopped", "unavailable", "problem"];
245
246export function backendOf(value: unknown): DesktopBackend | null {
247 return BACKENDS.find((backend) => backend === value) ?? null;
248}
249
250/** A diagnosis as the app sent it, or null when it is not one. */
251export function diagnosisOf(value: unknown): DesktopDiagnosis | null {
252 if (!value || typeof value !== "object") return null;
253 const given = value as Record<string, unknown>;
254 const code = text(given.code);
255 const title = text(given.title);
256 const tone = TONES.find((t) => t === given.tone);
257 if (!code || !title || !tone) return null;
258 const said: DesktopDiagnosis = { code, tone, title, cause: text(given.cause), fix: text(given.fix) };
259 const capabilities = Array.isArray(given.capabilities)
260 ? given.capabilities
261 .map(capabilityOf)
262 .filter((c): c is DesktopCapability => c !== null)
263 .slice(0, MAX_CAPABILITIES)
264 : [];
265 if (capabilities.length > 0) {
266 said.capabilities = capabilities;
267 if (given.in_capabilities === true) said.in_capabilities = true;
268 }
269 const update = updateOf(given.update);
270 if (update) said.update = update;
271 return said;
272}
273
274/** How the updates stand, as the app sent it, or null when it says nothing. */
275function updateOf(value: unknown): DesktopUpdate | null {
276 if (!value || typeof value !== "object") return null;
277 const given = value as Record<string, unknown>;
278 const checkedAt = text(given.checked_at);
279 const update: DesktopUpdate = {
280 checked_at: checkedAt && !Number.isNaN(Date.parse(checkedAt)) ? checkedAt : null,
281 ready: text(given.ready),
282 note: text(given.note),
283 };
284 return update.checked_at || update.ready || update.note ? update : null;
285}
286
287/** The most kinds of job a desktop reports (the server keeps as many). */
288const MAX_CAPABILITIES = 4;
289
290/** A kind of job as the app sent it, or null when it is not one. */
291function capabilityOf(value: unknown): DesktopCapability | null {
292 if (!value || typeof value !== "object") return null;
293 const given = value as Record<string, unknown>;
294 const id = text(given.id);
295 const label = text(given.label);
296 const title = text(given.title);
297 const tone = TONES.find((t) => t === given.tone);
298 if (!id || !label || !title || !tone) return null;
299 const said: DesktopCapability = { id, label, tone, title, cause: text(given.cause), fix: text(given.fix) };
300 const state = STATES.find((s) => s === given.state);
301 if (state) said.state = state;
302 for (const key of ["headline", "about", "runner", "isolation", "version", "update"] as const) {
303 const value = text(given[key]);
304 if (value) said[key] = value;
305 }
306 if (typeof given.enabled === "boolean") said.enabled = given.enabled;
307 const job = given.job && typeof given.job === "object" ? (given.job as Record<string, unknown>) : null;
308 const [kind, name, repo] = [text(job?.kind), text(job?.name), text(job?.repo)];
309 if (kind && name && repo) said.job = { kind, name, repo };
310 return said;
311}
312
313/**
314 * Where a kind of job's current work is on g1t: its run, from the runner
315 * it is (by its name, `build-box-linux`), else its repository's runs.
316 */
317export function jobHref(capability: Pick<DesktopCapability, "job" | "runner">, runners: Pick<Runner, "name" | "work">[]): string | null {
318 const job = capability.job;
319 if (!job) return null;
320 const work = capability.runner ? runners.find((runner) => runner.name === capability.runner)?.work : null;
321 if (work?.runId && work.repo) return work.kind === "workflow" ? workflowRunHref(work.repo, work.runId) : sandboxRunHref(work.repo);
322 return job.kind === "agent" ? sandboxRunHref(job.repo) : `/${job.repo}/actions`;
323}
324
325/** The words for how a desktop runs jobs, as its settings say them. */
326export const BACKEND_LABEL: Record<DesktopBackend, string> = {
327 sandboxed: "Sandboxed",
328 docker: "In Docker (not a sandbox)",
329 host: "Directly on the computer (not a sandbox)",
330};