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