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