Skip to content
131 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.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, DesktopDiagnosis, DesktopReport } from "@g1t/contracts";
22
23/** A fix, as a button: its id is the app's (`runner_control`). */
24export type DesktopRunnerAction = { id: string; label: string };
25
26/** What the app says of its runner (`runner::View` in the app). */
27export 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
43export type DesktopRunnerOffer = { signedIn: boolean; workspaces: { slug: string; name: string | null }[]; current: string | null };
44
45/** `window.g1tDesktop.runner`, from the app's bridge.js. */
46export 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`). */
56export 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. */
63export 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. */
85export const HEARTBEAT_MS = 4 * 60 * 1000;
86
87/** What goes to the server, without the buttons, which only work on that computer. */
88export 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. */
95export 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
100export 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. */
106export const text = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null);
107
108const BACKENDS: DesktopBackend[] = ["sandboxed", "docker", "host"];
109const TONES: DesktopDiagnosis["tone"][] = ["ok", "working", "off", "warning", "problem"];
110
111export 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. */
116export 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. */
127export 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};

This file's history is long; its oldest lines are credited to the oldest commit read.