Skip to content
159 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
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.21import type { DesktopBackend, DesktopCapability, DesktopDiagnosis, DesktopReport } from "@g1t/contracts";
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.22
23/** A fix, as a button: its id is the app's (`runner_control`). */
24export type DesktopRunnerAction = { id: string; label: string };
25
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.26/** A kind of job the sandbox runs, with its fixes' buttons (`diagnosis::CapabilityLine` in the app). */
27export type DesktopRunnerCapability = DesktopCapability & { actions: DesktopRunnerAction[] };
28
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.29/** What the app says of its runner (`runner::View` in the app). */
30export type DesktopRunnerView = {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.31 diagnosis: Omit<DesktopDiagnosis, "capabilities"> & { actions: DesktopRunnerAction[]; capabilities?: DesktopRunnerCapability[] };
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.32 closed: DesktopDiagnosis & { actions: DesktopRunnerAction[] };
33 running: boolean;
34 version: string | null;
35 backend: DesktopBackend;
36 workspace: string | null;
37 workspaces: { slug: string; name: string | null }[];
38 enabled: boolean;
39 paused: boolean;
40 link: string | null;
41 machine: string;
42 platform: string;
43 log: string[];
44};
45
46export type DesktopRunnerOffer = { signedIn: boolean; workspaces: { slug: string; name: string | null }[]; current: string | null };
47
48/** `window.g1tDesktop.runner`, from the app's bridge.js. */
49export type DesktopRunnerBridge = {
50 status(): Promise<DesktopRunnerView>;
51 offer(offer: DesktopRunnerOffer): Promise<string | null>;
52 register(answer: { workspace: string; token?: string | null; allowed?: boolean; error?: string | null }): Promise<void>;
53 act(action: string): Promise<void>;
54 onWanted(listener: (workspace: string) => void): () => void;
55 onStatus(listener: (view: DesktopRunnerView) => void): () => void;
56};
57
58/** The bridge's runner, inside the desktop app (lib/notify-client.ts describes the rest of `window.g1tDesktop`). */
59export function desktopRunner(): DesktopRunnerBridge | null {
60 if (typeof window === "undefined") return null;
61 const runner = window.g1tDesktop?.runner;
62 return runner && typeof runner.status === "function" ? runner : null;
63}
64
65/** This computer's runner, live, inside the desktop app; null elsewhere. */
66export function useDesktopRunner(): DesktopRunnerView | null {
67 const [view, setView] = useState<DesktopRunnerView | null>(null);
68 useEffect(() => {
69 const runner = desktopRunner();
70 if (!runner) return;
71 let live = true;
72 runner
73 .status()
74 .then((first) => {
75 if (live) setView(first);
76 })
77 .catch(() => {});
78 const stop = runner.onStatus((next) => setView(next));
79 return () => {
80 live = false;
81 stop();
82 };
83 }, []);
84 return view;
85}
86
87/** How often a desktop says it is still open, with nothing new: within the server's ten minutes. */
88export const HEARTBEAT_MS = 4 * 60 * 1000;
89
90/** What goes to the server, without the buttons, which only work on that computer. */
91export function reportOf(view: DesktopRunnerView): DesktopReport | null {
92 if (!view.workspace) return null;
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.93 const plain = ({ code, tone, title, cause, fix, capabilities }: DesktopDiagnosis): DesktopDiagnosis => {
94 const said: DesktopDiagnosis = { code, tone, title, cause, fix };
95 if (capabilities?.length) said.capabilities = capabilities.map(({ id, label, tone, title, cause, fix }) => ({ id, label, tone, title, cause: cause ?? null, fix: fix ?? null }));
96 return said;
97 };
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.98 return { workspace: view.workspace, machine: view.machine, backend: view.backend, version: view.version, diagnosis: plain(view.diagnosis), closed: plain(view.closed) };
99}
100
101/** Whether a report is due: something the Runners page shows changed, or the heartbeat. */
102export function reportDue(last: { key: string; at: number } | null, report: DesktopReport, now: number): boolean {
103 if (!last) return true;
104 return last.key !== reportKey(report) || now - last.at >= HEARTBEAT_MS;
105}
106
107export function reportKey(report: DesktopReport): string {
108 const { diagnosis } = report;
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.109 const capabilities = (diagnosis.capabilities ?? []).map((c) => [c.id, c.tone, c.title, c.cause, c.fix].join("\u0001"));
110 return [report.workspace, report.machine, report.backend, report.version, diagnosis.code, diagnosis.title, diagnosis.cause, diagnosis.fix, ...capabilities].join("\u0000");
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.111}
112
113/** A string with something in it, or null. */
114export const text = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null);
115
116const BACKENDS: DesktopBackend[] = ["sandboxed", "docker", "host"];
117const TONES: DesktopDiagnosis["tone"][] = ["ok", "working", "off", "warning", "problem"];
118
119export function backendOf(value: unknown): DesktopBackend | null {
120 return BACKENDS.find((backend) => backend === value) ?? null;
121}
122
123/** A diagnosis as the app sent it, or null when it is not one. */
124export function diagnosisOf(value: unknown): DesktopDiagnosis | null {
125 if (!value || typeof value !== "object") return null;
126 const given = value as Record<string, unknown>;
127 const code = text(given.code);
128 const title = text(given.title);
129 const tone = TONES.find((t) => t === given.tone);
130 if (!code || !title || !tone) return null;
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.131 const said: DesktopDiagnosis = { code, tone, title, cause: text(given.cause), fix: text(given.fix) };
132 const capabilities = Array.isArray(given.capabilities)
133 ? given.capabilities.map(capabilityOf).filter((c): c is DesktopCapability => c !== null).slice(0, MAX_CAPABILITIES)
134 : [];
135 if (capabilities.length > 0) said.capabilities = capabilities;
136 return said;
137}
138
139/** The most kinds of job a desktop reports (the server keeps as many). */
140const MAX_CAPABILITIES = 4;
141
142/** A kind of job as the app sent it, or null when it is not one. */
143function capabilityOf(value: unknown): DesktopCapability | null {
144 if (!value || typeof value !== "object") return null;
145 const given = value as Record<string, unknown>;
146 const id = text(given.id);
147 const label = text(given.label);
148 const title = text(given.title);
149 const tone = TONES.find((t) => t === given.tone);
150 if (!id || !label || !title || !tone) return null;
151 return { id, label, tone, title, cause: text(given.cause), fix: text(given.fix) };
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.152}
153
154/** The words for how a desktop runs jobs, as its settings say them. */
155export const BACKEND_LABEL: Record<DesktopBackend, string> = {
156 sandboxed: "Sandboxed",
157 docker: "In Docker (not a sandbox)",
158 host: "Directly on the computer (not a sandbox)",
159};

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