Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how. | 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 | } |