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