| 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 | |
| 21 | import type { DesktopBackend, DesktopCapability, DesktopCapabilityState, DesktopDiagnosis, DesktopJob, DesktopReport, DesktopUpdate, Runner } from "@g1t/contracts"; |
| 22 | |
| 23 | import { 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 | */ |
| 31 | export 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`). */ |
| 34 | export 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 | */ |
| 42 | export 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 | */ |
| 55 | export 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). */ |
| 64 | export 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 | */ |
| 87 | export 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. */ |
| 94 | export 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 | |
| 98 | export type DesktopRunnerOffer = { signedIn: boolean; workspaces: { slug: string; name: string | null }[]; current: string | null }; |
| 99 | |
| 100 | /** `window.g1tDesktop.runner`, from the app's bridge.js. */ |
| 101 | export 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`). */ |
| 112 | export 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. */ |
| 119 | export 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. */ |
| 141 | export 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. */ |
| 144 | function 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. */ |
| 160 | function 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. */ |
| 166 | export 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. */ |
| 182 | export 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 | |
| 187 | export 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. */ |
| 195 | export const text = (value: unknown): string | null => (typeof value === "string" && value.length > 0 ? value : null); |
| 196 | |
| 197 | const BACKENDS: DesktopBackend[] = ["sandboxed", "docker", "host"]; |
| 198 | const TONES: DesktopDiagnosis["tone"][] = ["ok", "working", "off", "warning", "problem"]; |
| 199 | const STATES: DesktopCapabilityState[] = ["busy", "idle", "starting", "downloading", "updating", "waiting", "stopped", "unavailable", "problem"]; |
| 200 | |
| 201 | export 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. */ |
| 206 | export 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. */ |
| 227 | function 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). */ |
| 240 | const MAX_CAPABILITIES = 4; |
| 241 | |
| 242 | /** A kind of job as the app sent it, or null when it is not one. */ |
| 243 | function 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 | */ |
| 269 | export 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. */ |
| 278 | export 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 | }; |