Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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