| 1 | import { env } from "cloudflare:workers"; |
| 2 | |
| 3 | import { |
| 4 | type RegistrationToken, |
| 5 | type Runner, |
| 6 | type RunnerGroup, |
| 7 | type RunnerSettings, |
| 8 | type RunnersOwner, |
| 9 | type User, |
| 10 | RUNNER_ACTIVITY_LIMIT, |
| 11 | runnersClient, |
| 12 | } from "@g1t/contracts"; |
| 13 | |
| 14 | import { instrumented } from "./perf.server"; |
| 15 | import { type CloudNow, type CloudPrice, type OwnWaiting, type RunnerCost, cloudNow, cloudPrices, ownWaiting, runnerCost } from "./runners"; |
| 16 | import { agents, billing } from "./services.server"; |
| 17 | |
| 18 | /** Self-hosted runners, kept by the actions service. */ |
| 19 | export const runners = runnersClient(instrumented("actions", env.ACTIONS)); |
| 20 | |
| 21 | export type RunnersData = { |
| 22 | runners: Runner[]; |
| 23 | /** A workspace's groups; empty for a repository. */ |
| 24 | groups: RunnerGroup[]; |
| 25 | settings: RunnerSettings | null; |
| 26 | /** The workspace's repositories, for choosing a group's. */ |
| 27 | repositories: string[]; |
| 28 | error: string | null; |
| 29 | }; |
| 30 | |
| 31 | export async function loadRunners(owner: RunnersOwner, actor: User, repositories: string[] = []): Promise<RunnersData> { |
| 32 | const [list, groups, settings] = await Promise.all([ |
| 33 | runners.list(actor, owner), |
| 34 | "workspace" in owner ? runners.groups(actor, owner.workspace) : Promise.resolve(null), |
| 35 | runners.settings(actor, owner), |
| 36 | ]); |
| 37 | return { |
| 38 | runners: list.ok ? list.value : [], |
| 39 | groups: groups?.ok ? groups.value : [], |
| 40 | settings: settings.ok ? settings.value : null, |
| 41 | repositories, |
| 42 | error: list.ok ? null : list.error.message, |
| 43 | }; |
| 44 | } |
| 45 | |
| 46 | /** The workspace's Runners page: its runners, and g1t's cloud beside them. */ |
| 47 | export type RunnersPageData = RunnersData & { |
| 48 | /** What runs on g1t's cloud now; null when neither the work nor the actions service answered. */ |
| 49 | cloud: CloudNow | null; |
| 50 | /** What waits for the workspace's own runners; null when the actions service did not answer. */ |
| 51 | waiting: OwnWaiting | null; |
| 52 | /** This month's machine time; null when billing did not answer. */ |
| 53 | cost: RunnerCost | null; |
| 54 | /** A minute on g1t's cloud, from the price book; null when it has none. */ |
| 55 | prices: CloudPrice[] | null; |
| 56 | /** Agent runs at once on g1t's cloud the plan allows; null for no cap, or unknown. */ |
| 57 | agentCap: number | null; |
| 58 | }; |
| 59 | |
| 60 | /** Active agent runs read: the work service's most in one list. */ |
| 61 | const RUN_PAGE = 200; |
| 62 | |
| 63 | const warn = (what: string) => (error: unknown) => { |
| 64 | console.warn(`runners: ${what} failed`, error); |
| 65 | return null; |
| 66 | }; |
| 67 | |
| 68 | /** |
| 69 | * Every read the workspace's Runners page makes, at once. The runners, |
| 70 | * groups and settings come as on a project's page; what runs on g1t's |
| 71 | * cloud, what waits, and this month's time each come from their own |
| 72 | * service, and one that does not answer leaves only its part saying so. |
| 73 | */ |
| 74 | export async function loadRunnersPage(workspace: string, actor: User, repositories: string[] = [], now = new Date()): Promise<RunnersPageData> { |
| 75 | const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10); |
| 76 | const until = now.toISOString().slice(0, 10); |
| 77 | const [base, activity, runs, report, book, entitlements] = await Promise.all([ |
| 78 | loadRunners({ workspace }, actor, repositories), |
| 79 | runners |
| 80 | .activity(actor, workspace) |
| 81 | .then((result) => (result.ok ? result.value : null)) |
| 82 | .catch(warn("runner activity")), |
| 83 | agents |
| 84 | .listRuns(actor, { workspace, active: true, limit: RUN_PAGE }) |
| 85 | .then((result) => (result.ok ? result.value : null)) |
| 86 | .catch(warn("agent runs")), |
| 87 | billing |
| 88 | .usageReport(workspace, actor, { from, until }) |
| 89 | .then((result) => (result.ok ? result.value : null)) |
| 90 | .catch(warn("usage report")), |
| 91 | billing.prices().catch(warn("prices")), |
| 92 | billing.entitlements(workspace).catch(warn("entitlements")), |
| 93 | ]); |
| 94 | return { |
| 95 | ...base, |
| 96 | cloud: cloudNow(runs, activity, RUN_PAGE, RUNNER_ACTIVITY_LIMIT), |
| 97 | waiting: ownWaiting(activity), |
| 98 | cost: runnerCost(report), |
| 99 | prices: cloudPrices(book?.prices ?? null), |
| 100 | agentCap: entitlements && entitlements.maxConcurrentAgents > 0 ? entitlements.maxConcurrentAgents : null, |
| 101 | }; |
| 102 | } |
| 103 | |
| 104 | export type RunnersAction = { |
| 105 | error?: string; |
| 106 | notice?: string; |
| 107 | /** A registration token just made: shown once, in the install steps. */ |
| 108 | token?: RegistrationToken; |
| 109 | }; |
| 110 | |
| 111 | /** Every form on the Runners page. */ |
| 112 | export async function actOnRunners(owner: RunnersOwner, actor: User, form: FormData): Promise<RunnersAction> { |
| 113 | const intent = String(form.get("intent") ?? ""); |
| 114 | const workspace = "workspace" in owner ? owner.workspace : owner.repo.namespace.toLowerCase(); |
| 115 | const list = (name: string) => |
| 116 | String(form.get(name) ?? "") |
| 117 | .split(/[\s,]+/) |
| 118 | .map((item) => item.trim()) |
| 119 | .filter(Boolean); |
| 120 | switch (intent) { |
| 121 | case "token": { |
| 122 | const made = await runners.createToken(actor, owner, String(form.get("group") ?? "") || undefined); |
| 123 | return made.ok ? { token: made.value } : { error: made.error.message }; |
| 124 | } |
| 125 | case "remove": { |
| 126 | const removed = await runners.remove(actor, owner, String(form.get("id") ?? "")); |
| 127 | return removed.ok ? { notice: `Removed ${String(form.get("name") ?? "the runner")}.` } : { error: removed.error.message }; |
| 128 | } |
| 129 | case "group": { |
| 130 | const id = String(form.get("id") ?? "") || undefined; |
| 131 | const repositories = form.get("reach") === "some" ? form.getAll("repository").map(String) : []; |
| 132 | if (form.get("reach") === "some" && repositories.length === 0) return { error: "Choose at least one repository, or every repository." }; |
| 133 | const saved = await runners.setGroup(actor, workspace, { |
| 134 | id, |
| 135 | name: String(form.get("name") ?? "").trim() || undefined, |
| 136 | repositories, |
| 137 | }); |
| 138 | return saved.ok ? { notice: `Saved the group ${saved.value.name}.` } : { error: saved.error.message }; |
| 139 | } |
| 140 | case "delete-group": { |
| 141 | const deleted = await runners.deleteGroup(actor, workspace, String(form.get("id") ?? "")); |
| 142 | return deleted.ok ? { notice: "Deleted the group; its runners joined the default group." } : { error: deleted.error.message }; |
| 143 | } |
| 144 | case "settings": { |
| 145 | const saved = await runners.setSettings(actor, owner, { |
| 146 | agents_on_self_hosted: form.get("agents") === "on", |
| 147 | agent_labels: list("agentLabels"), |
| 148 | fork_pull_requests: form.get("forks") === "on", |
| 149 | }); |
| 150 | return saved.ok ? { notice: "Saved." } : { error: saved.error.message }; |
| 151 | } |
| 152 | case "inherit": { |
| 153 | const saved = await runners.setSettings(actor, owner, { inherit: true }); |
| 154 | return saved.ok ? { notice: "This project follows the workspace's runner settings again." } : { error: saved.error.message }; |
| 155 | } |
| 156 | default: |
| 157 | return { error: "Nothing to do." }; |
| 158 | } |
| 159 | } |