| 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 | */ |
| 15 | import type { AgentRun, MeterLine, Price, RunnerActivity, UsageReport } from "@g1t/contracts"; |
| 16 | |
| 17 | /** One thing running in one of g1t's sandboxes. */ |
| 18 | export 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 | |
| 31 | export 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. */ |
| 40 | export type OwnWaiting = { tasks: number; jobs: number }; |
| 41 | |
| 42 | /** This month's machine time, from billing's usage report. */ |
| 43 | export 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. */ |
| 57 | export 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). */ |
| 60 | export const CLOUD_METERS = ["agent_sandbox", "sandbox"] as const; |
| 61 | export 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. */ |
| 64 | const PRICE_PARTS = ["sandbox_base_second", "sandbox_cpu_second"] as const; |
| 65 | const PRICE_WHOLE = "sandbox_second"; |
| 66 | |
| 67 | /** The repository page of an agent run. */ |
| 68 | export function agentRunHref(repo: string, runId: string): string { |
| 69 | return `/${repo}/agents/runs/${runId}`; |
| 70 | } |
| 71 | |
| 72 | /** The page of a workflow run. */ |
| 73 | export function workflowRunHref(repo: string, runId: string): string { |
| 74 | return `/${repo}/actions/runs/${runId}`; |
| 75 | } |
| 76 | |
| 77 | const 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 | */ |
| 84 | export 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. */ |
| 116 | export 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. */ |
| 122 | export 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. */ |
| 138 | export 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". */ |
| 153 | export 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". */ |
| 163 | export 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 | } |