Skip to content
163 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 /** People's computers with the desktop app, as each app last said; null when the actions service did not answer. */
58 desktops: DesktopRunner[] | null;
59};
60
61/** Active sandbox runs read: the work service's most in one list. */
62const RUN_PAGE = 200;
63
64const warn = (what: string) => (error: unknown) => {
65 console.warn(`runners: ${what} failed`, error);
66 return null;
67};
68
69/**
70 * Every read the workspace's Runners page makes, at once. The runners,
71 * groups and settings come as on a project's page; what runs on g1t's
72 * cloud, what waits, and this month's time each come from their own
73 * service, and one that does not answer leaves only its part saying so.
74 */
75export async function loadRunnersPage(workspace: string, actor: User, repositories: string[] = [], now = new Date()): Promise<RunnersPageData> {
76 const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10);
77 const until = now.toISOString().slice(0, 10);
78 const [base, activity, runs, report, book, desktops] = await Promise.all([
79 loadRunners({ workspace }, actor, repositories),
80 runners
81 .activity(actor, workspace)
82 .then((result) => (result.ok ? result.value : null))
83 .catch(warn("runner activity")),
84 agents
85 .listRuns(actor, { workspace, active: true, limit: RUN_PAGE })
86 .then((result) => (result.ok ? result.value : null))
87 .catch(warn("sandbox runs")),
88 billing
89 .usageReport(workspace, actor, { from, until })
90 .then((result) => (result.ok ? result.value : null))
91 .catch(warn("usage report")),
92 billing.prices().catch(warn("prices")),
93 runners
94 .desktops(actor, workspace)
95 .then((result) => (result.ok ? result.value : null))
96 .catch(warn("desktops")),
97 ]);
98 return {
99 ...base,
100 cloud: cloudNow(runs, activity, RUN_PAGE, RUNNER_ACTIVITY_LIMIT),
101 waiting: ownWaiting(activity),
102 cost: runnerCost(report),
103 prices: cloudPrices(book?.prices ?? null),
104 desktops,
105 };
106}
107
108export type RunnersAction = {
109 error?: string;
110 notice?: string;
111 /** A registration token just made: shown once, in the install steps. */
112 token?: RegistrationToken;
113};
114
115/** Every form on the Runners page. */
116export async function actOnRunners(owner: RunnersOwner, actor: User, form: FormData): Promise<RunnersAction> {
117 const intent = String(form.get("intent") ?? "");
118 const workspace = "workspace" in owner ? owner.workspace : owner.repo.namespace.toLowerCase();
119 const list = (name: string) =>
120 String(form.get(name) ?? "")
121 .split(/[\s,]+/)
122 .map((item) => item.trim())
123 .filter(Boolean);
124 switch (intent) {
125 case "token": {
126 const made = await runners.createToken(actor, owner, String(form.get("group") ?? "") || undefined);
127 return made.ok ? { token: made.value } : { error: made.error.message };
128 }
129 case "remove": {
130 const removed = await runners.remove(actor, owner, String(form.get("id") ?? ""));
131 return removed.ok ? { notice: `Removed ${String(form.get("name") ?? "the runner")}.` } : { error: removed.error.message };
132 }
133 case "group": {
134 const id = String(form.get("id") ?? "") || undefined;
135 const repositories = form.get("reach") === "some" ? form.getAll("repository").map(String) : [];
136 if (form.get("reach") === "some" && repositories.length === 0) return { error: "Choose at least one repository, or every repository." };
137 const saved = await runners.setGroup(actor, workspace, {
138 id,
139 name: String(form.get("name") ?? "").trim() || undefined,
140 repositories,
141 });
142 return saved.ok ? { notice: `Saved the group ${saved.value.name}.` } : { error: saved.error.message };
143 }
144 case "delete-group": {
145 const deleted = await runners.deleteGroup(actor, workspace, String(form.get("id") ?? ""));
146 return deleted.ok ? { notice: "Deleted the group; its runners joined the default group." } : { error: deleted.error.message };
147 }
148 case "settings": {
149 const saved = await runners.setSettings(actor, owner, {
150 agents_on_self_hosted: form.get("agents") === "on",
151 agent_labels: list("agentLabels"),
152 fork_pull_requests: form.get("forks") === "on",
153 });
154 return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
155 }
156 case "inherit": {
157 const saved = await runners.setSettings(actor, owner, { inherit: true });
158 return saved.ok ? { notice: "This project follows the workspace's runner settings again." } : { error: saved.error.message };
159 }
160 default:
161 return { error: "Nothing to do." };
162 }
163}