pr_01m47d24b0e6n91zwymwxg0vpx/services/runner/src/index.ts

1,013 lines39,785 bytesCodeBlame
1import { Container, type StopParams } from "@cloudflare/containers";
2import { WorkerEntrypoint } from "cloudflare:workers";
3
4import {
5 type CheckJob,
6 type G1tEvent,
7 type Issue,
8 type LifecycleJob,
9 type Plan,
10 type Comment,
11 type Pull,
12 type QueueJob,
13 type RepoPath,
14 type Result,
15 type RunHostedInput,
16 type RunnerApi,
17 type ServiceBinding,
18 type User,
19 type Viewer,
20 billingClient,
21 fail,
22 identityClient,
23 ok,
24 reposClient,
25 workClient,
26} from "@g1t/contracts";
27
28import { type AgentRoutes, type AgentTask, canReachModel, modelEnv } from "./model-env";
29
30export interface RunnerEnv {
31 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
32 IDENTITY: ServiceBinding;
33 REPOS: ServiceBinding;
34 WORK: ServiceBinding;
35 BILLING: ServiceBinding;
36 /**
37 * Secret. The provider's key. Leave it unset when the gateway holds the
38 * key, so that no sandbox ever does.
39 */
40 ANTHROPIC_API_KEY?: string;
41 /**
42 * Comma-separated usernames who may start agents while workspaces are not
43 * paying with real money: when billing is off, or its cards are pretend.
44 * Once billing is live, anyone may, and the workspace is charged.
45 */
46 HOSTED_AGENT_USERS: string;
47 /**
48 * Which model each kind of work runs on, as JSON:
49 * `{ implement, review, update }`, each `{ modelName, model }`.
50 * `modelName` is what people see; `model` is sent to the provider.
51 */
52 AGENT_ROUTES: string;
53 /**
54 * A Cloudflare AI Gateway id. When set, model traffic goes through that
55 * gateway, which is where logging, spend limits, caching and fallback
56 * between providers are configured. Empty sends it to the provider
57 * directly.
58 */
59 AI_GATEWAY_ID: string;
60 CLOUDFLARE_ACCOUNT_ID: string;
61 /** Secret. Authenticates to the gateway, if it requires it. */
62 AI_GATEWAY_TOKEN?: string;
63}
64
65/** A run that takes longer than this has its token expire under it. */
66const TOKEN_TTL_SECONDS = 2 * 60 * 60;
67/** How g1t's own agent is labelled. What runs behind it is g1t's choice. */
68const AGENT = "g1t-agent";
69
70/**
71 * What a sandbox is doing: an agent working on a pull request as someone,
72 * or a run of acceptance checks.
73 */
74type Run =
75 | { kind: "agent"; actor: User; repo: RepoPath; number: number }
76 | { kind: "checks"; runId: string; token: string }
77 | { kind: "review"; runId: string; token: string }
78 /**
79 * A catch-up merge reports its own failure in the session. One g1t
80 * started by itself names the pull request, so that a failure stops it
81 * from trying again.
82 */
83 | { kind: "update"; pullId?: string }
84 /** The author sent back to address failed checks or a review. */
85 | { kind: "revise"; pullId: string }
86 /** An agent turning an outcome into a plan. */
87 | { kind: "plan"; planId: string; token: string }
88 /** One combined state of a merge queue, being built and checked. */
89 | { kind: "queue"; entryId: string; token: string };
90type RunRequest = Run & { envVars: Record<string, string> };
91
92/** Long enough to clone, install and test; then the token stops working. */
93const CHECKS_TOKEN_TTL_SECONDS = 45 * 60;
94
95/**
96 * One sandbox, for one agent or one run of checks. The image's entrypoint
97 * is the g1t runner, which does the work and exits; this class only starts
98 * it and cleans up if it dies without reporting.
99 */
100export class AttemptSandbox extends Container<RunnerEnv> {
101 sleepAfter = "45m";
102
103 async run(request: RunRequest): Promise<void> {
104 const { envVars, ...run } = request;
105 await this.ctx.storage.put("run", run);
106 await this.start({ envVars, enableInternet: true });
107 }
108
109 override async onStop({ exitCode }: StopParams): Promise<void> {
110 if (exitCode === 0) return;
111 const run = await this.ctx.storage.get<Run>("run");
112 if (!run) return;
113 const work = workClient(this.env.WORK);
114 if (run.kind === "checks") {
115 // Refused harmlessly if the run did report before it stopped.
116 await work.reportChecks(run.runId, run.token, {
117 error: "The sandbox stopped before the checks finished.",
118 });
119 return;
120 }
121 if (run.kind === "review") {
122 await work.failReview(run.runId, run.token, "The sandbox stopped before the review was written.");
123 return;
124 }
125 if (run.kind === "queue") {
126 // Refused harmlessly if the state was reported before it stopped.
127 await work.failQueue(run.entryId, run.token, "The sandbox stopped before the state was checked.");
128 return;
129 }
130 if (run.kind === "plan") {
131 // Refused harmlessly if the plan was reported before it stopped.
132 await work.failPlan(run.planId, run.token, "The sandbox stopped before the plan was written.");
133 return;
134 }
135 if (run.kind === "update" || run.kind === "revise") {
136 if (run.pullId) {
137 await work.stall(
138 run.pullId,
139 run.kind === "update"
140 ? "The agent could not catch up with the branch this will land on. Its session says why."
141 : "The agent could not address what the checks or the review found. Its session says why.",
142 );
143 }
144 return;
145 }
146 // The runner closes its own pull request when it fails. This covers a
147 // sandbox that was killed before it could; closing twice is refused
148 // harmlessly.
149 await work.closePull(run.actor, run.repo, run.number);
150 }
151}
152
153/** How many other pull requests an agent is told about. */
154const MAX_IN_FLIGHT = 12;
155/** How many of each one's files are named. */
156const MAX_FILES_NAMED = 8;
157
158/**
159 * The other work going on in a repository while an agent works in it: the
160 * pull requests in progress, what each is for and which files it changes.
161 * Told to every agent, so that dozens working at once stay out of each
162 * other's way, and recorded in its session so people can see what it knew.
163 */
164type InFlight = { prompt: string | null; note: string | null };
165
166function describeInFlight(others: Pull[], mine: Set<string>): InFlight {
167 if (others.length === 0) return { prompt: null, note: null };
168 const shown = [...others]
169 // Pull requests changing the same files first: those are the ones to watch.
170 .sort(
171 (a, b) =>
172 Number(b.files.some((f) => mine.has(f.path))) - Number(a.files.some((f) => mine.has(f.path))) ||
173 b.number - a.number,
174 )
175 .slice(0, MAX_IN_FLIGHT);
176 const lines = shown.map((pull) => {
177 const files = pull.files.map((file) => file.path);
178 const named = files.slice(0, MAX_FILES_NAMED).join(", ") + (files.length > MAX_FILES_NAMED ? `, and ${files.length - MAX_FILES_NAMED} more` : "");
179 const shared = files.filter((path) => mine.has(path));
180 return `- #${pull.number} ${pull.title}${pull.issue != null ? ` (for issue #${pull.issue})` : ""}, by ${pull.agent}: ${
181 files.length ? `changes ${named}` : "nothing pushed yet"
182 }${shared.length ? `. It also changes ${shared.join(", ")}, which you are changing.` : ""}`;
183 });
184 const prompt = [
185 "Other agents and people are working in this repository at the same time. These pull requests are in progress, and any of them may merge before yours:",
186 lines.join("\n"),
187 "Keep your change to what your task needs. Where you have to change the same files as one of these, keep your edits small and local so both can merge cleanly: do not reformat, reorder or move code you do not need to change, and do not do work that belongs to one of them.",
188 ].join("\n\n");
189 const overlapping = shown.filter((pull) => pull.files.some((f) => mine.has(f.path)));
190 const note =
191 `Told about ${others.length} other pull ${others.length === 1 ? "request" : "requests"} in progress: ${shown.map((p) => `#${p.number}`).join(", ")}.` +
192 (overlapping.length ? ` ${overlapping.map((p) => `#${p.number}`).join(", ")} ${overlapping.length === 1 ? "changes" : "change"} the same files.` : "");
193 return { prompt, note };
194}
195
196/** What a g1t agent may do through g1t's own tools, in its repository. */
197const AGENT_OPERATIONS = [
198 "get_repo",
199 "list_issues",
200 "get_issue",
201 "list_labels",
202 "create_issue",
203 "add_comment",
204 "list_pull_requests",
205 "get_pull_request",
206 "get_pull_request_changes",
207 "read_session",
208 "get_merge_queue",
209 "list_events",
210];
211
212/** How an agent is told to use g1t's tools to work with the others. */
213const WORKING_WITH_OTHERS =
214 "You have g1t's own tools (mcp__g1t__…) for this repository. Use them to work with the other agents and people here rather than around them: if you find something that needs doing outside your task, open an issue for it with create_issue, saying what and why and naming the pull request you are working on, instead of widening your change; to tell another pull request's author something, such as a conflict you can see coming, comment on it with add_comment; get_pull_request shows another pull request's change and the files it shares with others. Mention anything you opened or said in your summary.";
215
216/** Longest that what people said on a pull request is passed on. */
217const MAX_PEOPLE_SAID_CHARS = 6000;
218/** Accounts that are g1t itself, not people. */
219const NOT_PEOPLE = new Set(["g1t-agent", "g1t"]);
220
221/**
222 * What people have said on a pull request, for an agent working on it: a
223 * person's request outranks the issue's wording and any agent's review.
224 */
225function describePeopleSaid(comments: Comment[]): string | null {
226 const said = comments
227 .filter((comment) => comment.kind !== "event" && !NOT_PEOPLE.has(comment.author.username))
228 .map((comment) => {
229 const where = comment.path ? ` on ${comment.path}${comment.line ? ` line ${comment.line}` : ""}` : "";
230 const verdict =
231 comment.verdict === "request_changes"
232 ? " (asked for changes)"
233 : comment.verdict === "approve"
234 ? " (approved)"
235 : "";
236 return `- ${comment.author.username}${where}${verdict}: ${comment.body.trim()}`;
237 });
238 if (said.length === 0) return null;
239 let text = said.join("\n");
240 if (text.length > MAX_PEOPLE_SAID_CHARS) text = `…${text.slice(-MAX_PEOPLE_SAID_CHARS)}`;
241 return [
242 "What people have said on this pull request, oldest first. A change a person asked for is in scope, even where it goes beyond the issue, and it outranks any agent's review: never ask for it to be undone, and never undo it.",
243 text,
244 ].join("\n\n");
245}
246
247/** What the author is told when sent back to a pull request it made. */
248function buildRevisionPrompt(job: LifecycleJob, inFlight: string | null, peopleSaid: string | null): string {
249 const parts = [
250 "You are a coding agent working in the git repository checked out in the current directory. It holds a change you made earlier, which is open as a pull request.",
251 job.issue
252 ? `It is for issue #${job.issue.number}: ${job.issue.title}\n\n${job.issue.body}`
253 : `The pull request: ${job.title}`,
254 job.description && `What you said you changed:\n\n${job.description}`,
255 job.feedback,
256 job.issue?.checks.length &&
257 `These commands must pass when you are done. Run them if the tools are installed:\n${job.issue.checks.map((check) => `- ${check}`).join("\n")}`,
258 peopleSaid,
259 inFlight,
260 WORKING_WITH_OTHERS,
261 "Address every point above, and nothing else. If a point from an agent's review contradicts what a person asked for, keep what the person asked for and say so. If you disagree with a point, leave the code as it is and say why. Commit your work with a clear message. Do not push; that is done for you. Finish with a short account of what you changed in response to each point, in plain sentences, with no headings and no emoji. Say what you did not verify.",
262 ];
263 return parts.filter(Boolean).join("\n\n");
264}
265
266function buildPrompt(issue: Issue, instructions: string, inFlight: string | null): string {
267 const parts = [
268 "You are a coding agent working in the git repository checked out in the current directory.",
269 `Issue #${issue.number}: ${issue.title}`,
270 issue.body,
271 ];
272 if (issue.checks.length > 0) {
273 parts.push(
274 `These commands must pass when you are done. Run them if the tools are installed:\n${issue.checks.map((check) => `- ${check}`).join("\n")}`,
275 );
276 }
277 if (instructions) parts.push(instructions);
278 if (inFlight) parts.push(inFlight);
279 parts.push(WORKING_WITH_OTHERS);
280 parts.push(
281 "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: plain sentences, no headings, no emoji, no checklists, and nothing about whether anything was committed or pushed. Say what you did not verify.",
282 );
283 return parts.filter(Boolean).join("\n\n");
284}
285
286export default class RunnerService
287 extends WorkerEntrypoint<RunnerEnv>
288 implements RunnerApi
289{
290 /**
291 * The JSON protocol the Rust services speak: `POST /rpc/<method>` with the
292 * arguments as the body. The site calls the methods below directly; the
293 * API, which is Rust, reaches them through here. Only bound services can.
294 */
295 async fetch(request: Request): Promise<Response> {
296 const { pathname } = new URL(request.url);
297 if (request.method === "POST" && pathname === "/rpc/run") {
298 const args = (await request.json()) as {
299 actor: User;
300 repo: RepoPath;
301 issue: number;
302 instructions?: string;
303 };
304 return Response.json(
305 await this.run(args.actor, args.repo, args.issue, { instructions: args.instructions }),
306 );
307 }
308 if (request.method === "POST" && pathname === "/rpc/plan") {
309 const args = (await request.json()) as { actor: User; repo: RepoPath; brief: string };
310 return Response.json(await this.plan(args.actor, args.repo, args.brief));
311 }
312 if (request.method === "POST" && pathname === "/rpc/apply_plan") {
313 const args = (await request.json()) as {
314 actor: User;
315 repo: RepoPath;
316 planId: string;
317 assign?: boolean;
318 keep?: number[];
319 };
320 return Response.json(
321 await this.applyPlan(args.actor, args.repo, args.planId, {
322 assign: args.assign,
323 keep: args.keep,
324 }),
325 );
326 }
327 return new Response("Not found\n", { status: 404 });
328 }
329
330 /**
331 * What a sandbox needs to reach the model routed for `task`, having
332 * opened the run the repository's workspace will be charged for. Refused
333 * when that workspace has no credit.
334 */
335 private async modelEnv(
336 task: AgentTask,
337 repo: RepoPath,
338 pull: number,
339 ): Promise<Result<Record<string, string>>> {
340 const routes: AgentRoutes = JSON.parse(this.env.AGENT_ROUTES);
341 const ticket = await billingClient(this.env.BILLING).startRun({
342 workspace: repo.namespace,
343 repo,
344 number: pull,
345 task,
346 model: routes[task].modelName,
347 });
348 if (!ticket.ok) return ticket;
349 const vars = modelEnv(this.env, routes, task, {
350 repo: `${repo.namespace}/${repo.name}`,
351 pull,
352 });
353 if (ticket.value) {
354 // How the sandbox says what the run cost. Kept from the agent.
355 vars.BILLING_RUN = ticket.value.runId;
356 vars.BILLING_TOKEN = ticket.value.token;
357 }
358 return ok(vars);
359 }
360
361 /** The same, for a step g1t takes by itself: a refusal stops the step. */
362 private async modelEnvOrThrow(
363 task: AgentTask,
364 repo: RepoPath,
365 pull: number,
366 ): Promise<Record<string, string>> {
367 const vars = await this.modelEnv(task, repo, pull);
368 if (!vars.ok) throw new Error(vars.error.message);
369 return vars.value;
370 }
371
372 /**
373 * Whether sandboxes may be started on this person's say-so. Where
374 * workspaces pay with real money, anyone's. Until then g1t is paying, or
375 * the cards are pretend, so only the people listed.
376 */
377 private async enabledFor(username: string): Promise<boolean> {
378 const billing = await billingClient(this.env.BILLING).status();
379 if (billing.enabled && billing.live) return true;
380 return this.env.HOSTED_AGENT_USERS.split(",")
381 .map((name) => name.trim())
382 .includes(username);
383 }
384
385 private async allowed(viewer: Viewer): Promise<boolean> {
386 if (!viewer || !canReachModel(this.env)) return false;
387 return this.enabledFor(viewer.username);
388 }
389
390 /**
391 * Events from the bus. Each one that could change what a pull request
392 * needs next moves it along: checks when it becomes ready or its head
393 * moves, then whatever the lifecycle says once those have nothing to do.
394 */
395 async queue(batch: MessageBatch<G1tEvent>): Promise<void> {
396 for (const message of batch.messages) {
397 const event = message.body;
398 switch (event.type) {
399 // A pull request opened from a branch is ready from the start; one
400 // opened as a draft is refused until it is marked ready.
401 case "pull.opened":
402 case "pull.ready":
403 case "pull.updated":
404 if (!(await this.startChecks(event.data.pullId))) {
405 await this.advance(event.data.pullId);
406 }
407 // An agent that has finished its change leaves room for another.
408 if (event.type === "pull.ready") await this.startReady(event.data.repoId);
409 break;
410 case "checks.completed":
411 case "review.completed":
412 await this.advance(event.data.pullId);
413 break;
414 // Something joined, left or landed: test the next batch if none is.
415 case "queue.changed":
416 await this.buildQueue(event.data.repoId);
417 break;
418 // A person approved or asked for changes: one may let it merge,
419 // the other sends the agent back.
420 case "comment.created":
421 if (event.data.pullId && event.data.verdict) await this.advance(event.data.pullId);
422 break;
423 // Someone merged a pull request that is behind: bring it up to
424 // date, and the work service lands it when the push arrives.
425 case "pull.merge_requested":
426 await this.catchUpForMerge(event.data.pullId);
427 break;
428 // The branch the others would land on has moved.
429 case "pull.merged":
430 await this.advanceAll(event.data.repoId);
431 break;
432 // Something an issue was waiting on has finished, or an agent has
433 // stopped and left room for another.
434 case "issue.closed":
435 case "pull.closed":
436 await this.startReady(event.data.repoId);
437 break;
438 }
439 message.ack();
440 }
441 }
442
443 /** A sweep, for steps whose trigger was missed or whose sandbox died. */
444 async scheduled(): Promise<void> {
445 await this.advanceAll();
446 await this.startReady();
447 }
448
449 /**
450 * Puts a g1t agent on each issue that was waiting for one and can now
451 * have it: nothing it depends on is still open, and its repository has
452 * room. One that cannot be started goes back in the queue.
453 */
454 private async startReady(repoId?: string): Promise<void> {
455 const work = workClient(this.env.WORK);
456 for (const issue of await work.readyIssues(repoId)) {
457 const started = await this.run(issue.actor, issue.repo, issue.number).catch(
458 (error: unknown) => fail("conflict", String(error)),
459 );
460 if (!started.ok) await work.queueIssue(issue.actor, issue.repo, issue.number, true);
461 }
462 }
463
464 private async advanceAll(repoId?: string): Promise<void> {
465 const pulls = await workClient(this.env.WORK).managedPulls(repoId);
466 for (const pullId of pulls) await this.advance(pullId);
467 }
468
469 /**
470 * Takes the next step for a pull request g1t is seeing through, if it is
471 * g1t's turn. The work service decides and claims the step, so calling
472 * this twice starts nothing twice.
473 */
474 private async advance(pullId: string): Promise<void> {
475 const work = workClient(this.env.WORK);
476 const next = await work.advance(pullId);
477 if (next.action === "none") return;
478 const { job } = next;
479 try {
480 if (!canReachModel(this.env) || !(await this.enabledFor(job.author.username))) {
481 throw new Error("g1t agents are not enabled for this pull request's author.");
482 }
483 if (next.action === "review") {
484 const started = await this.startReview(pullId);
485 if (!started.ok) throw new Error(started.error.message);
486 } else if (next.action === "revise") {
487 await this.startRevision(job);
488 } else {
489 await this.startCatchUp(job);
490 }
491 } catch (error) {
492 // Stop, and say so on the pull request, instead of trying forever.
493 await work.stall(
494 pullId,
495 `g1t could not start the next step: ${error instanceof Error ? error.message : String(error)}`,
496 );
497 }
498 }
499
500 /** Brings a pull request up to date because a merge is waiting on it. */
501 private async catchUpForMerge(pullId: string): Promise<void> {
502 const work = workClient(this.env.WORK);
503 const job = await work.catchUpJob(pullId);
504 if (!job) return;
505 try {
506 if (!canReachModel(this.env)) throw new Error("g1t agents are not set up.");
507 await this.startCatchUp(job);
508 } catch (error) {
509 await work.stall(
510 pullId,
511 `g1t could not bring this up to date: ${error instanceof Error ? error.message : String(error)}`,
512 );
513 }
514 }
515
516 private async startCatchUp(job: LifecycleJob): Promise<void> {
517 await this.startUpdate({
518 actor: job.author,
519 repo: job.repo,
520 number: job.number,
521 remote: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
522 branch: job.branch ?? job.defaultBranch,
523 defaultBranch: job.defaultBranch,
524 about: [
525 job.title,
526 job.description,
527 job.issue && `Issue #${job.issue.number}: ${job.issue.title}\n\n${job.issue.body}`,
528 ],
529 pullId: job.pullId,
530 });
531 }
532
533 /**
534 * What else is in progress in `repo` besides pull request `number`, told
535 * to the agent working on it and noted in its session.
536 */
537 private async inFlight(actor: User, repo: RepoPath, number: number): Promise<string | null> {
538 const work = workClient(this.env.WORK);
539 const listed = await work.listPulls(repo, actor, "open");
540 if (!listed.ok) return null;
541 const mine = new Set(listed.value.find((pull) => pull.number === number)?.files.map((file) => file.path) ?? []);
542 const others = listed.value.filter((pull) => pull.number !== number);
543 const { prompt, note } = describeInFlight(others, mine);
544 if (note) await work.appendSession(actor, repo, number, [{ kind: "note", text: note }]);
545 return prompt;
546 }
547
548 /**
549 * Starts the next batch of a repository's merge queue, if it has one
550 * ready: a sandbox per entry, all at once, each building the default
551 * branch with that entry and everything ahead of it.
552 */
553 private async buildQueue(repoId: string): Promise<void> {
554 const work = workClient(this.env.WORK);
555 const jobs = await work.queueBuild(repoId);
556 // A state whose sandbox could not start fails at once, rather than
557 // holding the queue until it times out.
558 await Promise.all(
559 jobs.map((job) =>
560 this.startQueueRun(job).catch((error: unknown) =>
561 work.failQueue(job.entryId, job.token, `Its sandbox could not start: ${String(error)}`),
562 ),
563 ),
564 );
565 }
566
567 private async startQueueRun(job: QueueJob): Promise<void> {
568 // To read the changes and push the tested state, as a member.
569 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
570 job.actor,
571 `Merge queue for ${job.repo.namespace}/${job.repo.name}`,
572 CHECKS_TOKEN_TTL_SECONDS,
573 );
574 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
575 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`queue-${job.entryId}-${job.baseCommit}`));
576 await sandbox.run({
577 kind: "queue",
578 entryId: job.entryId,
579 token: job.token,
580 envVars: {
581 MODE: "queue",
582 G1T_API: "https://api.g1t.sh",
583 QUEUE_ENTRY: job.entryId,
584 QUEUE_TOKEN: job.token,
585 G1T_USER: job.actor.username,
586 G1T_TOKEN: token,
587 BASE_REMOTE: remote(job.repo),
588 BASE_COMMIT: job.baseCommit,
589 QUEUE_BRANCH: job.branch,
590 STACK: JSON.stringify(
591 job.stack.map((item) => ({
592 number: item.number,
593 title: item.title,
594 remote: remote(item.source),
595 branch: item.branch,
596 commit: item.commit,
597 })),
598 ),
599 CHECKS: JSON.stringify(job.checks),
600 CONTRACT_CHECKS: JSON.stringify(job.contractChecks),
601 },
602 });
603 }
604
605 /** What people have said on pull request `number`, told to agents working on it. */
606 private async peopleSaid(actor: User, repo: RepoPath, number: number): Promise<string | null> {
607 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
608 return found.ok ? describePeopleSaid(found.value.comments) : null;
609 }
610
611 /** A token for g1t's own tools, for an agent working for `actor` in `repo`. */
612 private async agentToken(actor: User, repo: RepoPath): Promise<string> {
613 const { token } = await identityClient(this.env.IDENTITY).createAgentToken(
614 actor,
615 { repo, operations: AGENT_OPERATIONS },
616 TOKEN_TTL_SECONDS,
617 );
618 return token;
619 }
620
621 private async startRevision(job: LifecycleJob): Promise<void> {
622 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
623 job.author,
624 `g1t agent revising ${job.repo.namespace}/${job.repo.name}#${job.number}`,
625 TOKEN_TTL_SECONDS,
626 );
627 const sandbox = this.env.SANDBOX.get(
628 this.env.SANDBOX.idFromName(`revise-${job.pullId}-${job.round}`),
629 );
630 await sandbox.run({
631 kind: "revise",
632 pullId: job.pullId,
633 envVars: {
634 MODE: "revise",
635 G1T_API: "https://api.g1t.sh",
636 G1T_TOKEN: token,
637 G1T_USER: job.author.username,
638 G1T_REPO: `${job.repo.namespace}/${job.repo.name}`,
639 PULL_NUMBER: String(job.number),
640 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
641 COMMIT_MESSAGE: `Address feedback on #${job.number}`,
642 G1T_AGENT_TOKEN: await this.agentToken(job.author, job.repo),
643 // Revised from where the branch it will land on is now.
644 UPSTREAM_REMOTE: `https://g1t.sh/${job.repo.namespace}/${job.repo.name}.git`,
645 UPSTREAM_BRANCH: job.defaultBranch,
646 PROMPT: buildRevisionPrompt(
647 job,
648 await this.inFlight(job.author, job.repo, job.number),
649 await this.peopleSaid(job.author, job.repo, job.number),
650 ),
651 ...(await this.modelEnvOrThrow("implement", job.repo, job.number)),
652 },
653 });
654 }
655
656 /**
657 * Runs a pull request's acceptance checks in a sandbox of its own. Does
658 * nothing when there is nothing to run.
659 */
660 private async startChecks(pullId: string): Promise<boolean> {
661 const work = workClient(this.env.WORK);
662 const started = await work.startChecks(pullId);
663 if (!started.ok) return false;
664 const job: CheckJob = started.value;
665 // Checks are commands one person wrote, run against code another
666 // pushed, on g1t's machines. In the preview they run only when one of
667 // the two is someone sandboxes are enabled for.
668 if (
669 !(await this.enabledFor(job.requestedBy)) &&
670 !(await this.enabledFor(job.author.username))
671 ) {
672 await work.reportChecks(job.runId, job.token, { skip: true });
673 return false;
674 }
675 // To read the commit, which may be private, as the one who pushed it.
676 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
677 job.author,
678 `Checks on ${job.repo.namespace}/${job.repo.name}#${job.number}`,
679 CHECKS_TOKEN_TTL_SECONDS,
680 );
681 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.runId));
682 await sandbox.run({
683 kind: "checks",
684 runId: job.runId,
685 token: job.token,
686 envVars: {
687 MODE: "checks",
688 G1T_API: "https://api.g1t.sh",
689 CHECK_RUN: job.runId,
690 CHECK_TOKEN: job.token,
691 G1T_USER: job.author.username,
692 G1T_TOKEN: token,
693 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
694 GIT_COMMIT: job.commit,
695 CHECKS: JSON.stringify(job.commands),
696 },
697 });
698 return true;
699 }
700
701 /**
702 * A refusal if `actor` may not put g1t agents to work on `repo`: agents
703 * are not enabled for them, or the work would be charged to a workspace
704 * they do not belong to or that has no credit.
705 */
706 private async refusal(actor: User, repo: RepoPath): Promise<Result<never> | null> {
707 if (!(await this.allowed(actor))) {
708 return fail("forbidden", "g1t agents are not enabled for your account.");
709 }
710 const billing = billingClient(this.env.BILLING);
711 if (!(await billing.status()).enabled) return null;
712 const member = (actor.workspaces ?? []).some(
713 (membership) => membership.slug === repo.namespace.toLowerCase(),
714 );
715 if (!member) {
716 return fail(
717 "forbidden",
718 `Agents are charged to the ${repo.namespace} workspace, so only its members can put them to work here.`,
719 );
720 }
721 const credit = await billing.canStart(repo.namespace);
722 return credit.ok ? null : credit;
723 }
724
725 async update(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> {
726 const refused = await this.refusal(actor, repo);
727 if (refused) return refused;
728 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
729 if (!found.ok) return found;
730 const { pull, issue, behind } = found.value;
731 if (pull.status !== "draft" && pull.status !== "open") {
732 return fail("conflict", `This pull request is already ${pull.status}.`);
733 }
734 if (!behind) return fail("conflict", "This pull request is already up to date.");
735 // The result is pushed as the person asking, so they must be able to
736 // push there: a fork takes pushes only from whoever opened it.
737 const member = (actor.workspaces ?? []).some(
738 (membership) => membership.slug === repo.namespace,
739 );
740 if (pull.fork ? pull.author.id !== actor.id : !member) {
741 return fail(
742 "forbidden",
743 pull.fork
744 ? "Only whoever opened this pull request can update it."
745 : "Only members of the workspace can update this pull request.",
746 );
747 }
748 const defaultBranch = await this.defaultBranch(repo, actor);
749 await this.startUpdate({
750 actor,
751 repo,
752 number,
753 remote: pull.fork
754 ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`
755 : `https://g1t.sh/${repo.namespace}/${repo.name}.git`,
756 branch: pull.branch ?? defaultBranch,
757 defaultBranch,
758 about: [pull.title, pull.body, issue && `Issue #${issue.number}: ${issue.title}\n\n${issue.body}`],
759 });
760 return ok(true);
761 }
762
763 /** Starts a sandbox that merges the default branch into a pull request. */
764 private async startUpdate(update: {
765 /** Who the result is pushed as. */
766 actor: User;
767 repo: RepoPath;
768 number: number;
769 /** The pull request's source, and the branch of it holding the change. */
770 remote: string;
771 branch: string;
772 defaultBranch: string;
773 /** What the pull request is for, given to the agent on a conflict. */
774 about: (string | null | undefined | false)[];
775 /** Set when g1t started this itself. */
776 pullId?: string;
777 }): Promise<void> {
778 const { actor, repo, number } = update;
779 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
780 actor,
781 `Catching up ${repo.namespace}/${repo.name}#${number}`,
782 TOKEN_TTL_SECONDS,
783 );
784 const sandbox = this.env.SANDBOX.get(
785 this.env.SANDBOX.idFromName(`update-${repo.namespace}-${repo.name}-${number}-${Date.now()}`),
786 );
787 await sandbox.run({
788 kind: "update",
789 pullId: update.pullId,
790 envVars: {
791 MODE: "update",
792 G1T_API: "https://api.g1t.sh",
793 G1T_TOKEN: token,
794 G1T_USER: actor.username,
795 G1T_REPO: `${repo.namespace}/${repo.name}`,
796 PULL_NUMBER: String(number),
797 GIT_REMOTE: update.remote,
798 GIT_BRANCH: update.branch,
799 UPSTREAM_REMOTE: `https://g1t.sh/${repo.namespace}/${repo.name}.git`,
800 UPSTREAM_BRANCH: update.defaultBranch,
801 PROMPT: update.about.filter(Boolean).join("\n\n"),
802 ...(await this.modelEnvOrThrow("update", repo, number)),
803 },
804 });
805 }
806
807 async review(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> {
808 const refused = await this.refusal(actor, repo);
809 if (refused) return refused;
810 // Whoever can see a pull request can ask for it to be reviewed.
811 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
812 if (!found.ok) return found;
813 if (found.value.reviewPending) {
814 return fail("conflict", "A g1t agent is already reviewing this pull request.");
815 }
816 return this.startReview(found.value.pull.id);
817 }
818
819 /** Starts a sandbox in which a g1t agent reviews a pull request. */
820 private async startReview(pullId: string): Promise<Result<boolean>> {
821 const started = await workClient(this.env.WORK).startReview(pullId);
822 if (!started.ok) return started;
823 const job = started.value;
824 const { repo, number } = job;
825 // To read the commit, which may be private, as the one who pushed it.
826 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
827 job.author,
828 `Review of ${repo.namespace}/${repo.name}#${number}`,
829 CHECKS_TOKEN_TTL_SECONDS,
830 );
831 const about = [
832 `Pull request #${job.number}: ${job.title}`,
833 job.description,
834 job.issue &&
835 `It is for issue #${job.issue.number}: ${job.issue.title}\n\n${job.issue.body}`,
836 job.issue?.checks.length &&
837 `The issue's acceptance checks: ${job.issue.checks.join("; ")}`,
838 await this.peopleSaid(job.author, repo, number),
839 ];
840 const model = await this.modelEnv("review", repo, number);
841 if (!model.ok) {
842 await workClient(this.env.WORK).failReview(job.runId, job.token, model.error.message);
843 return model;
844 }
845 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.runId));
846 await sandbox.run({
847 kind: "review",
848 runId: job.runId,
849 token: job.token,
850 envVars: {
851 MODE: "review",
852 G1T_API: "https://api.g1t.sh",
853 REVIEW_RUN: job.runId,
854 REVIEW_TOKEN: job.token,
855 G1T_USER: job.author.username,
856 G1T_TOKEN: token,
857 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
858 GIT_COMMIT: job.commit,
859 UPSTREAM_REMOTE: `https://g1t.sh/${job.repo.namespace}/${job.repo.name}.git`,
860 UPSTREAM_BRANCH: job.defaultBranch,
861 PROMPT: about.filter(Boolean).join("\n\n"),
862 ...model.value,
863 },
864 });
865 return ok(true);
866 }
867
868 private async defaultBranch(repo: RepoPath, viewer: Viewer): Promise<string> {
869 const found = await reposClient(this.env.REPOS).get(repo, viewer);
870 return found.ok ? found.value.defaultBranch : "main";
871 }
872
873 async recheck(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> {
874 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
875 if (!found.ok) return found;
876 const { pull } = found.value;
877 const member = (actor.workspaces ?? []).some(
878 (membership) => membership.slug === repo.namespace,
879 );
880 if (!member && pull.author.id !== actor.id) {
881 return fail(
882 "forbidden",
883 "Only whoever opened a pull request, or a member of the workspace, can run its checks.",
884 );
885 }
886 return (await this.startChecks(pull.id))
887 ? ok(true)
888 : fail("conflict", "There are no checks to run for this pull request right now.");
889 }
890
891 async plan(actor: User, repo: RepoPath, brief: string): Promise<Result<{ planId: string }>> {
892 const refused = await this.refusal(actor, repo);
893 if (refused) return refused;
894 const work = workClient(this.env.WORK);
895 const started = await work.startPlan(actor, repo, brief);
896 if (!started.ok) return started;
897 const job = started.value;
898 const model = await this.modelEnv("plan", repo, 0);
899 if (!model.ok) {
900 await work.failPlan(job.planId, job.token, model.error.message);
901 return model;
902 }
903 // To read the repository, which may be private, as the one planning.
904 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
905 actor,
906 `Planning for ${repo.namespace}/${repo.name}`,
907 CHECKS_TOKEN_TTL_SECONDS,
908 );
909 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.planId));
910 await sandbox.run({
911 kind: "plan",
912 planId: job.planId,
913 token: job.token,
914 envVars: {
915 MODE: "plan",
916 G1T_API: "https://api.g1t.sh",
917 PLAN_ID: job.planId,
918 PLAN_TOKEN: job.token,
919 G1T_USER: actor.username,
920 G1T_TOKEN: token,
921 GIT_REMOTE: `https://g1t.sh/${repo.namespace}/${repo.name}.git`,
922 PROMPT: job.brief,
923 ...model.value,
924 },
925 });
926 return ok({ planId: job.planId });
927 }
928
929 async applyPlan(
930 actor: User,
931 repo: RepoPath,
932 planId: string,
933 options: { assign?: boolean; keep?: number[] } = {},
934 ): Promise<Result<Plan>> {
935 if (options.assign) {
936 const refused = await this.refusal(actor, repo);
937 if (refused) return refused;
938 }
939 const applied = await workClient(this.env.WORK).applyPlan(actor, repo, planId, options);
940 if (!applied.ok) return applied;
941 // Agents start on everything that depends on nothing; the rest follow
942 // as what they depend on merges.
943 if (options.assign) await this.startReady(applied.value.repoId);
944 return applied;
945 }
946
947 async enabled(viewer: Viewer): Promise<boolean> {
948 return await this.allowed(viewer);
949 }
950
951 async run(
952 actor: User,
953 repo: RepoPath,
954 issueNumber: number,
955 input: RunHostedInput = {},
956 ): Promise<Result<Pull>> {
957 const refused = await this.refusal(actor, repo);
958 if (refused) return refused;
959 const work = workClient(this.env.WORK);
960
961 const found = await work.getIssue(repo, issueNumber, actor);
962 if (!found.ok) return found;
963 const { issue } = found.value;
964
965 const opened = await work.openPull(actor, repo, {
966 issue: issue.number,
967 agent: AGENT,
968 runtime: "hosted",
969 });
970 if (!opened.ok) return opened;
971 const pull = opened.value;
972 // Opened without a branch, so it has a fork.
973 const fork = pull.fork!;
974
975 const model = await this.modelEnv("implement", repo, pull.number);
976 if (!model.ok) {
977 await work.closePull(actor, repo, pull.number);
978 return model;
979 }
980
981 // The sandbox acts as the person who assigned the issue, through a
982 // token that only lives as long as a run can.
983 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
984 actor,
985 `g1t agent on ${repo.namespace}/${repo.name}#${pull.number}`,
986 TOKEN_TTL_SECONDS,
987 );
988 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(pull.id));
989 await sandbox.run({
990 kind: "agent",
991 actor,
992 repo,
993 number: pull.number,
994 envVars: {
995 G1T_API: "https://api.g1t.sh",
996 G1T_TOKEN: token,
997 G1T_USER: actor.username,
998 G1T_REPO: `${repo.namespace}/${repo.name}`,
999 PULL_NUMBER: String(pull.number),
1000 GIT_REMOTE: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
1001 COMMIT_MESSAGE: issue.title,
1002 G1T_AGENT_TOKEN: await this.agentToken(actor, repo),
1003 PROMPT: buildPrompt(
1004 issue,
1005 input.instructions?.trim() ?? "",
1006 await this.inFlight(actor, repo, pull.number),
1007 ),
1008 ...model.value,
1009 },
1010 });
1011 return ok(pull);
1012 }
1013}