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