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