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 | */ | |
| 19 | import { useEffect, useState } from "react"; | |
| 20 | ||
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 21 | import type { DesktopBackend, DesktopCapability, DesktopCapabilityState, DesktopDiagnosis, DesktopJob, DesktopReport, Runner } 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 | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 23 | import { agentRunHref, workflowRunHref } from "./runners.ts"; |
| 24 | ||
| 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. | 25 | /** A fix, as a button: its id is the app's (`runner_control`). */ |
| 26 | export type DesktopRunnerAction = { id: string; label: string }; | |
| 27 | ||
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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 | */ | |
| 34 | export 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 | }; | |
| 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. | 41 | |
| 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. | 42 | /** What the app says of its runner (`runner::View` in the app). */ |
| 43 | export 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. | 44 | 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. | 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 | ||
| 59 | export type DesktopRunnerOffer = { signedIn: boolean; workspaces: { slug: string; name: string | null }[]; current: string | null }; | |
| 60 | ||
| 61 | /** `window.g1tDesktop.runner`, from the app's bridge.js. */ | |
| 62 | export 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>; | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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>; | |
| 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. | 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`). */ | |
| 73 | export 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. */ | |
| 80 | export 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. */ | |
| 102 | export const HEARTBEAT_MS = 4 * 60 * 1000; | |
| 103 | ||
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 104 | /** A kind of job as the server keeps it: what it says, without what only works or changes on that computer. */ |
| 105 | function 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 | ||
| 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. | 119 | /** What goes to the server, without the buttons, which only work on that computer. */ |
| 120 | export function reportOf(view: DesktopRunnerView): DesktopReport | null { | |
| 121 | if (!view.workspace) return null; | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 122 | const plain = ({ code, tone, title, cause, fix, capabilities, in_capabilities }: DesktopDiagnosis): DesktopDiagnosis => { |
| 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. | 123 | const said: DesktopDiagnosis = { code, tone, title, cause, fix }; |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 124 | if (capabilities?.length) { |
| 125 | said.capabilities = capabilities.map(plainCapability); | |
| 126 | if (in_capabilities) said.in_capabilities = true; | |
| 127 | } | |
| 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. | 128 | return said; |
| 129 | }; | |
| 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. | 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. */ | |
| 134 | export 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 | ||
| 139 | export function reportKey(report: DesktopReport): string { | |
| 140 | const { diagnosis } = report; | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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"); | |
| 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. | 143 | } |
| 144 | ||
| 145 | /** A string with something in it, or null. */ | |
| 146 | export const text = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null); | |
| 147 | ||
| 148 | const BACKENDS: DesktopBackend[] = ["sandboxed", "docker", "host"]; | |
| 149 | const TONES: DesktopDiagnosis["tone"][] = ["ok", "working", "off", "warning", "problem"]; | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 150 | const STATES: DesktopCapabilityState[] = ["busy", "idle", "starting", "downloading", "updating", "waiting", "stopped", "unavailable", "problem"]; |
| 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. | 151 | |
| 152 | export 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. */ | |
| 157 | export 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; | |
| 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. | 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 | : []; | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 168 | if (capabilities.length > 0) { |
| 169 | said.capabilities = capabilities; | |
| 170 | if (given.in_capabilities === true) said.in_capabilities = true; | |
| 171 | } | |
| 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. | 172 | return said; |
| 173 | } | |
| 174 | ||
| 175 | /** The most kinds of job a desktop reports (the server keeps as many). */ | |
| 176 | const MAX_CAPABILITIES = 4; | |
| 177 | ||
| 178 | /** A kind of job as the app sent it, or null when it is not one. */ | |
| 179 | function 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; | |
| Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page. | 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 | */ | |
| 205 | export 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`; | |
| 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. | 211 | } |
| 212 | ||
| 213 | /** The words for how a desktop runs jobs, as its settings say them. */ | |
| 214 | export 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 | }; |
This file's history is long; its oldest lines are credited to the oldest commit read.