pr_01m47d24b0e6n91zwymwxg0vpx/services/runner/src/index.ts

300 lines10,498 bytesCodeBlame
1import { Container, type StopParams } from "@cloudflare/containers";
2import { WorkerEntrypoint } from "cloudflare:workers";
3
4import {
5 type AgentModel,
6 type CheckJob,
7 type G1tEvent,
8 type Issue,
9 type Pull,
10 type RepoPath,
11 type Result,
12 type RunHostedInput,
13 type RunnerApi,
14 type ServiceBinding,
15 type User,
16 type Viewer,
17 fail,
18 identityClient,
19 ok,
20 workClient,
21} from "@g1t/contracts";
22
23import { type ConfiguredModel, modelEnv } from "./model-env";
24
25export interface RunnerEnv {
26 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
27 IDENTITY: ServiceBinding;
28 WORK: ServiceBinding;
29 /** Secret. The model key the hosted agent runs on. */
30 ANTHROPIC_API_KEY?: string;
31 /**
32 * Comma-separated usernames allowed to start hosted agents. Runs spend the
33 * key above, so this stays an allowlist until accounts bring their own.
34 */
35 HOSTED_AGENT_USERS: string;
36 /**
37 * The models offered, as JSON:
38 * `[{ id, label, description, modelName, model }]`. `modelName` is what
39 * people see; `model` is the identifier sent to the provider.
40 */
41 AGENT_MODELS: string;
42 /**
43 * A Cloudflare AI Gateway id. When set, model traffic goes through that
44 * gateway, which is where logging, spend limits, caching and fallback
45 * between providers are configured. Empty sends it to the provider
46 * directly.
47 */
48 AI_GATEWAY_ID: string;
49 CLOUDFLARE_ACCOUNT_ID: string;
50 /** Secret. Needed only if the gateway requires authentication. */
51 AI_GATEWAY_TOKEN?: string;
52}
53
54const MAX_AGENTS_PER_RUN = 5;
55/** A run that takes longer than this has its token expire under it. */
56const TOKEN_TTL_SECONDS = 2 * 60 * 60;
57/** How g1t's own agent is labelled. What runs behind it is g1t's choice. */
58const AGENT = "g1t-agent";
59
60/**
61 * What a sandbox is doing: an agent working on a pull request as someone,
62 * or a run of acceptance checks.
63 */
64type Run =
65 | { kind: "agent"; actor: User; repo: RepoPath; number: number }
66 | { kind: "checks"; runId: string; token: string };
67type RunRequest = Run & { envVars: Record<string, string> };
68
69/** Long enough to clone, install and test; then the token stops working. */
70const CHECKS_TOKEN_TTL_SECONDS = 45 * 60;
71
72/**
73 * One sandbox, for one agent or one run of checks. The image's entrypoint
74 * is the g1t runner, which does the work and exits; this class only starts
75 * it and cleans up if it dies without reporting.
76 */
77export class AttemptSandbox extends Container<RunnerEnv> {
78 sleepAfter = "45m";
79
80 async run(request: RunRequest): Promise<void> {
81 const { envVars, ...run } = request;
82 await this.ctx.storage.put("run", run);
83 await this.start({ envVars, enableInternet: true });
84 }
85
86 override async onStop({ exitCode }: StopParams): Promise<void> {
87 if (exitCode === 0) return;
88 const run = await this.ctx.storage.get<Run>("run");
89 if (!run) return;
90 const work = workClient(this.env.WORK);
91 if (run.kind === "checks") {
92 // Refused harmlessly if the run did report before it stopped.
93 await work.reportChecks(run.runId, run.token, {
94 error: "The sandbox stopped before the checks finished.",
95 });
96 return;
97 }
98 // The runner closes its own pull request when it fails. This covers a
99 // sandbox that was killed before it could; closing twice is refused
100 // harmlessly.
101 await work.closePull(run.actor, run.repo, run.number);
102 }
103}
104
105function buildPrompt(issue: Issue, instructions: string): string {
106 const parts = [
107 "You are a coding agent working in the git repository checked out in the current directory.",
108 `Issue #${issue.number}: ${issue.title}`,
109 issue.body,
110 ];
111 if (issue.checks.length > 0) {
112 parts.push(
113 `These commands must pass when you are done. Run them if the tools are installed:\n${issue.checks.map((check) => `- ${check}`).join("\n")}`,
114 );
115 }
116 if (instructions) parts.push(instructions);
117 parts.push(
118 "Make the change and keep it focused on the issue. Commit your work with a clear message. Do not push; that is done for you. Finish with a short summary of what you changed and why. It becomes the description of your pull request, so write it for a reviewer and leave out whether anything was committed or pushed.",
119 );
120 return parts.filter(Boolean).join("\n\n");
121}
122
123export default class RunnerService
124 extends WorkerEntrypoint<RunnerEnv>
125 implements RunnerApi
126{
127 /** A Worker must have an event handler; this service is RPC-only. */
128 fetch(): Response {
129 return new Response("Not found\n", { status: 404 });
130 }
131
132 private configuredModels(): ConfiguredModel[] {
133 return JSON.parse(this.env.AGENT_MODELS);
134 }
135
136 /** Whether sandboxes may be started on this person's say-so. */
137 private enabledFor(username: string): boolean {
138 return this.env.HOSTED_AGENT_USERS.split(",")
139 .map((name) => name.trim())
140 .includes(username);
141 }
142
143 private allowed(viewer: Viewer): boolean {
144 if (!viewer || !this.env.ANTHROPIC_API_KEY) return false;
145 return this.enabledFor(viewer.username);
146 }
147
148 /** Events from the bus: a pull request was opened, became ready, or moved. */
149 async queue(batch: MessageBatch<G1tEvent>): Promise<void> {
150 for (const message of batch.messages) {
151 const event = message.body;
152 // A pull request opened from a branch is ready from the start; one
153 // opened as a draft is refused below until it is marked ready.
154 if (
155 event.type === "pull.opened" ||
156 event.type === "pull.ready" ||
157 event.type === "pull.updated"
158 ) {
159 await this.startChecks(event.data.pullId);
160 }
161 message.ack();
162 }
163 }
164
165 /**
166 * Runs a pull request's acceptance checks in a sandbox of its own. Does
167 * nothing when there is nothing to run.
168 */
169 private async startChecks(pullId: string): Promise<boolean> {
170 const work = workClient(this.env.WORK);
171 const started = await work.startChecks(pullId);
172 if (!started.ok) return false;
173 const job: CheckJob = started.value;
174 // Checks are commands one person wrote, run against code another
175 // pushed, on g1t's machines. In the preview they run only when one of
176 // the two is someone sandboxes are enabled for.
177 if (!this.enabledFor(job.requestedBy) && !this.enabledFor(job.author.username)) {
178 await work.reportChecks(job.runId, job.token, { skip: true });
179 return false;
180 }
181 // To read the commit, which may be private, as the one who pushed it.
182 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
183 job.author,
184 `Checks on ${job.repo.namespace}/${job.repo.name}#${job.number}`,
185 CHECKS_TOKEN_TTL_SECONDS,
186 );
187 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.runId));
188 await sandbox.run({
189 kind: "checks",
190 runId: job.runId,
191 token: job.token,
192 envVars: {
193 MODE: "checks",
194 G1T_API: "https://api.g1t.sh",
195 CHECK_RUN: job.runId,
196 CHECK_TOKEN: job.token,
197 G1T_USER: job.author.username,
198 G1T_TOKEN: token,
199 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
200 GIT_COMMIT: job.commit,
201 CHECKS: JSON.stringify(job.commands),
202 },
203 });
204 return true;
205 }
206
207 async recheck(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> {
208 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
209 if (!found.ok) return found;
210 const { pull } = found.value;
211 const member = (actor.workspaces ?? []).some(
212 (membership) => membership.slug === repo.namespace,
213 );
214 if (!member && pull.author.id !== actor.id) {
215 return fail(
216 "forbidden",
217 "Only whoever opened a pull request, or a member of the workspace, can run its checks.",
218 );
219 }
220 return (await this.startChecks(pull.id))
221 ? ok(true)
222 : fail("conflict", "There are no checks to run for this pull request right now.");
223 }
224
225 async models(viewer: Viewer): Promise<AgentModel[]> {
226 if (!this.allowed(viewer)) return [];
227 return this.configuredModels().map(({ id, label, description, modelName }) => ({
228 id,
229 label,
230 description,
231 modelName,
232 }));
233 }
234
235 async run(
236 actor: User,
237 repo: RepoPath,
238 issueNumber: number,
239 input: RunHostedInput,
240 ): Promise<Result<Pull[]>> {
241 if (!this.allowed(actor)) {
242 return fail("forbidden", "g1t agents are not enabled for your account.");
243 }
244 const models = this.configuredModels();
245 const model = input.model
246 ? models.find((candidate) => candidate.id === input.model)
247 : models[0];
248 if (!model) return fail("invalid", "That model is not available.");
249 const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), MAX_AGENTS_PER_RUN);
250 const identity = identityClient(this.env.IDENTITY);
251 const work = workClient(this.env.WORK);
252
253 const found = await work.getIssue(repo, issueNumber, actor);
254 if (!found.ok) return found;
255 const { issue } = found.value;
256 const prompt = buildPrompt(issue, input.instructions?.trim() ?? "");
257
258 const pulls: Pull[] = [];
259 for (let i = 0; i < count; i++) {
260 const opened = await work.openPull(actor, repo, {
261 issue: issue.number,
262 agent: AGENT,
263 runtime: "hosted",
264 });
265 // The first failure is the answer; later ones mean some already run.
266 if (!opened.ok) return pulls.length ? ok(pulls) : opened;
267 const pull = opened.value;
268 // Opened without a branch, so it has a fork.
269 const fork = pull.fork!;
270 pulls.push(pull);
271
272 // The sandbox acts as the person who started it, through a token
273 // that only lives as long as a run can.
274 const { token } = await identity.createAccessToken(
275 actor,
276 `g1t agent on ${repo.namespace}/${repo.name}#${pull.number}`,
277 TOKEN_TTL_SECONDS,
278 );
279 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(pull.id));
280 await sandbox.run({
281 kind: "agent",
282 actor,
283 repo,
284 number: pull.number,
285 envVars: {
286 G1T_API: "https://api.g1t.sh",
287 G1T_TOKEN: token,
288 G1T_USER: actor.username,
289 G1T_REPO: `${repo.namespace}/${repo.name}`,
290 PULL_NUMBER: String(pull.number),
291 GIT_REMOTE: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
292 COMMIT_MESSAGE: issue.title,
293 PROMPT: prompt,
294 ...modelEnv(this.env, model),
295 },
296 });
297 }
298 return ok(pulls);
299 }
300}