| 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 | */ |
| 17 | import type { AgentRun, MeterLine, Price, RunnerActivity, UsageReport } from "@g1t/contracts"; |
| 18 | |
| 19 | /** One thing running in one of g1t's sandboxes. */ |
| 20 | export 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 | |
| 33 | export 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. */ |
| 42 | export type OwnWaiting = { tasks: number; jobs: number }; |
| 43 | |
| 44 | /** This month's machine time, from billing's usage report. */ |
| 45 | export 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. */ |
| 59 | export 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. */ |
| 62 | export const CLOUD_METERS = ["agent_sandbox", "sandbox"] as const; |
| 63 | export 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. */ |
| 66 | const PRICE_PARTS = ["sandbox_base_second", "sandbox_cpu_second"] as const; |
| 67 | const PRICE_WHOLE = "sandbox_second"; |
| 68 | |
| 69 | /** Where a sandbox run (checks, the merge queue) shows: its pull request, else the repository's pull requests. */ |
| 70 | export 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. */ |
| 75 | export function workflowRunHref(repo: string, runId: string): string { |
| 76 | return `/${repo}/actions/runs/${runId}`; |
| 77 | } |
| 78 | |
| 79 | const 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 | */ |
| 86 | export 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. */ |
| 118 | export 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. */ |
| 124 | export 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. */ |
| 140 | export 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". */ |
| 155 | export 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". */ |
| 165 | export 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 | } |