Skip to content
159 linesCodeBlameRaw
1import { env } from "cloudflare:workers";
2
3import {
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
14import { instrumented } from "./perf.server";
15import { type CloudNow, type CloudPrice, type OwnWaiting, type RunnerCost, cloudNow, cloudPrices, ownWaiting, runnerCost } from "./runners";
16import { agents, billing } from "./services.server";
17
18/** Self-hosted runners, kept by the actions service. */
19export const runners = runnersClient(instrumented("actions", env.ACTIONS));
20
21export 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
31export 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. */
47export 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. */
61const RUN_PAGE = 200;
62
63const 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 */
74export 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
104export 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. */
112export 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}