Skip to content
168 linesCodeBlameRaw
1/**
2 * The Runners page's shaping: what runs on g1t's cloud now, what waits for
3 * the workspace's own runners, and what each cost this month, from the
4 * actions, work and billing services' own answers. Pure, so it can be
5 * tested; the reads are in ./runners.server.ts.
6 *
7 * - **On g1t's cloud now** is the workspace's sandbox runs (checks, the
8 * merge queue, merge checks) that are running
9 * (from the work service) and not handed to one of its own runners
10 * (`runner_activity`'s `handed_over`), and its workflow jobs running in
11 * g1t's sandboxes (`cloud_jobs`).
12 * - **This month** is billing's usage report from the first of the month:
13 * runner minutes by machine are g1t's cloud, at price (older months
14 * name sandbox time by the second, and agent sandbox time);
15 * self-hosted runner time is the workspace's own, at $0.
16 */
17import type { AgentRun, MeterLine, RunnerActivity, UsageReport } from "@g1t/contracts";
18import { machineSize, machines } from "@g1t/contracts/instances";
19
20/** One thing running in one of g1t's sandboxes. */
21export type CloudWork = {
22 kind: "agent" | "workflow";
23 id: string;
24 /** What it is: a pull request's title, a job's name. */
25 title: string;
26 /** The run's kind for a sandbox run (`checks`, `queue`…); the job's workflow run for a job. */
27 detail: string;
28 /** `owner/name`. */
29 repo: string;
30 href: string;
31 startedAt: string | null;
32};
33
34export type CloudNow = {
35 running: CloudWork[];
36 /** Sandbox runs and workflow jobs waiting to start in g1t's sandboxes. */
37 queued: number;
38 /** A list reached its limit: there may be more than shown. */
39 more: boolean;
40};
41
42/** Waiting for one of the workspace's own runners. */
43export type OwnWaiting = { tasks: number; jobs: number };
44
45/** This month's machine time, from billing's usage report. */
46export type RunnerCost = {
47 from: string;
48 until: string;
49 /** g1t's cloud: sandbox time. */
50 cloudSeconds: number;
51 /** At price, before what paid for it. */
52 cloudMicros: number;
53 /** The workspace's own runners: self-hosted runner time, always $0. */
54 ownSeconds: number;
55 /** g1t charges nothing for now: the figures are usage at cost. */
56 free: boolean;
57};
58
59/** A machine on g1t's cloud and its price a minute, from the machine catalogue. */
60export type CloudPrice = { machine: string; title: string; perMinuteMicros: number };
61
62/**
63 * Billing's meters for machine time (`meters()` in services/billing/src/report.rs):
64 * runner minutes by machine (`runner_2-core`), and by the second, sandbox time
65 * from before minutes (`agent_sandbox` in still older months' reports).
66 */
67export const RUNNER_METER_PREFIX = "runner_";
68export const CLOUD_SECOND_METERS = ["agent_sandbox", "sandbox"] as const;
69export const OWN_METER = "self_hosted";
70
71/** Where a sandbox run (checks, the merge queue) shows: its pull request, else the repository's pull requests. */
72export function sandboxRunHref(repo: string, number?: number | null): string {
73 return number != null ? `/${repo}/pull/${number}` : `/${repo}/pulls`;
74}
75
76/** The page of a workflow run. */
77export function workflowRunHref(repo: string, runId: string): string {
78 return `/${repo}/actions/runs/${runId}`;
79}
80
81const repoName = (run: Pick<AgentRun, "repo">) => `${run.repo.namespace}/${run.repo.name}`;
82
83/**
84 * What runs on g1t's cloud now. `runs` are the workspace's active sandbox
85 * runs (null when the work service did not answer); `activity` is the
86 * actions service's (null when it did not). Null when neither answered.
87 */
88export function cloudNow(runs: AgentRun[] | null, activity: RunnerActivity | null, runLimit: number, jobLimit: number): CloudNow | null {
89 if (!runs && !activity) return null;
90 const handed = new Set((activity?.handed_over ?? []).map((task) => task.run_id).filter((id): id is string => !!id));
91 const ours = (runs ?? []).filter((run) => !handed.has(run.id));
92 const running: CloudWork[] = [
93 ...ours
94 .filter((run) => run.status === "running")
95 .map((run) => ({
96 kind: "agent" as const,
97 id: run.id,
98 title: run.title ?? (run.number != null ? `#${run.number}` : repoName(run)),
99 detail: run.kind,
100 repo: repoName(run),
101 href: sandboxRunHref(repoName(run), run.number),
102 startedAt: run.startedAt ?? run.createdAt,
103 })),
104 ...(activity?.cloud_jobs ?? []).map((job) => ({
105 kind: "workflow" as const,
106 id: job.id,
107 title: job.name,
108 detail: job.run_id,
109 repo: job.repo,
110 href: workflowRunHref(job.repo, job.run_id),
111 startedAt: job.started_at,
112 })),
113 ].sort((a, b) => (b.startedAt ?? "").localeCompare(a.startedAt ?? ""));
114 const queued = ours.filter((run) => run.status === "queued").length + (activity?.cloud_jobs_queued ?? 0);
115 const more = (runs?.length ?? 0) >= runLimit || (activity?.cloud_jobs.length ?? 0) >= jobLimit;
116 return { running, queued, more };
117}
118
119/** What waits for one of the workspace's own runners: sandbox runs not yet taken, and workflow jobs. */
120export function ownWaiting(activity: RunnerActivity | null): OwnWaiting | null {
121 if (!activity) return null;
122 return { tasks: activity.handed_over.filter((task) => task.status === "queued").length, jobs: activity.self_hosted_jobs_queued };
123}
124
125/** This month's machine time from billing's usage report; null when billing did not answer. */
126export function runnerCost(report: UsageReport | null): RunnerCost | null {
127 if (!report) return null;
128 const meters: MeterLine[] = report.products.flatMap((product) => product.meters);
129 const sum = (keys: readonly string[], pick: (meter: MeterLine) => number) =>
130 meters.filter((meter) => keys.includes(meter.key)).reduce((total, meter) => total + pick(meter), 0);
131 const runnerKeys = meters.filter((meter) => meter.key.startsWith(RUNNER_METER_PREFIX)).map((meter) => meter.key);
132 return {
133 from: report.from,
134 until: report.until,
135 // Minutes as charged, and sandbox time from before them.
136 cloudSeconds: sum(runnerKeys, (meter) => meter.quantity * 60) + sum(CLOUD_SECOND_METERS, (meter) => meter.quantity),
137 cloudMicros: sum([...runnerKeys, ...CLOUD_SECOND_METERS], (meter) => meter.micros + (meter.pendingMicros ?? 0)),
138 ownSeconds: sum([OWN_METER], (meter) => meter.quantity),
139 free: report.free,
140 };
141}
142
143/** Each machine on g1t's cloud and its price a minute, from the machine catalogue. */
144export function cloudPrices(): CloudPrice[] {
145 return machines().map((instance) => ({
146 machine: instance.machine,
147 title: `${instance.machine}: ${machineSize(instance)}`,
148 perMinuteMicros: instance.microsPerMinute,
149 }));
150}
151
152/** Seconds as people read machine time: "45s", "12 min", "3.4 h". */
153export function machineTime(seconds: number): string {
154 if (seconds <= 0) return "0 min";
155 if (seconds < 60) return `${Math.round(seconds)}s`;
156 const minutes = seconds / 60;
157 if (minutes < 100) return `${Math.round(minutes)} min`;
158 const hours = minutes / 60;
159 return `${hours < 10 ? hours.toFixed(1) : Math.round(hours).toLocaleString("en-US")} h`;
160}
161
162/** A price per minute, to a tenth of a cent or finer: "$0.0012". */
163export function perMinute(micros: number): string {
164 if (micros <= 0) return "$0";
165 const dollars = micros / 1_000_000;
166 const digits = dollars >= 0.01 ? 3 : Math.min(6, Math.max(4, 2 - Math.floor(Math.log10(dollars))));
167 return `$${dollars.toFixed(digits).replace(/0+$/, "").replace(/\.$/, "")}`;
168}