g1t/services/runner/src/index.ts

1,015 lines39,874 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 // Messages people send it while it works, picked up between steps.
211 "take_messages",
212];
213
214/** How an agent is told to use g1t's tools to work with the others. */
215const WORKING_WITH_OTHERS =
216 "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.";
217
218/** Longest that what people said on a pull request is passed on. */
219const MAX_PEOPLE_SAID_CHARS = 6000;
220/** Accounts that are g1t itself, not people. */
221const NOT_PEOPLE = new Set(["g1t-agent", "g1t"]);
222
223/**
224 * What people have said on a pull request, for an agent working on it: a
225 * person's request outranks the issue's wording and any agent's review.
226 */
227function describePeopleSaid(comments: Comment[]): string | null {
228 const said = comments
229 .filter((comment) => comment.kind !== "event" && !NOT_PEOPLE.has(comment.author.username))
230 .map((comment) => {
231 const where = comment.path ? ` on ${comment.path}${comment.line ? ` line ${comment.line}` : ""}` : "";
232 const verdict =
233 comment.verdict === "request_changes"
234 ? " (asked for changes)"
235 : comment.verdict === "approve"
236 ? " (approved)"
237 : "";
238 return `- ${comment.author.username}${where}${verdict}: ${comment.body.trim()}`;
239 });
240 if (said.length === 0) return null;
241 let text = said.join("\n");
242 if (text.length > MAX_PEOPLE_SAID_CHARS) text = `…${text.slice(-MAX_PEOPLE_SAID_CHARS)}`;
243 return [
244 "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.",
245 text,
246 ].join("\n\n");
247}
248
249/** What the author is told when sent back to a pull request it made. */
250function buildRevisionPrompt(job: LifecycleJob, inFlight: string | null, peopleSaid: string | null): string {
251 const parts = [
252 "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.",
253 job.issue
254 ? `It is for issue #${job.issue.number}: ${job.issue.title}\n\n${job.issue.body}`
255 : `The pull request: ${job.title}`,
256 job.description && `What you said you changed:\n\n${job.description}`,
257 job.feedback,
258 job.issue?.checks.length &&
259 `These commands must pass when you are done. Run them if the tools are installed:\n${job.issue.checks.map((check) => `- ${check}`).join("\n")}`,
260 peopleSaid,
261 inFlight,
262 WORKING_WITH_OTHERS,
263 "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.",
264 ];
265 return parts.filter(Boolean).join("\n\n");
266}
267
268function buildPrompt(issue: Issue, instructions: string, inFlight: string | null): string {
269 const parts = [
270 "You are a coding agent working in the git repository checked out in the current directory.",
271 `Issue #${issue.number}: ${issue.title}`,
272 issue.body,
273 ];
274 if (issue.checks.length > 0) {
275 parts.push(
276 `These commands must pass when you are done. Run them if the tools are installed:\n${issue.checks.map((check) => `- ${check}`).join("\n")}`,
277 );
278 }
279 if (instructions) parts.push(instructions);
280 if (inFlight) parts.push(inFlight);
281 parts.push(WORKING_WITH_OTHERS);
282 parts.push(
283 "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.",
284 );
285 return parts.filter(Boolean).join("\n\n");
286}
287
288export default class RunnerService
289 extends WorkerEntrypoint<RunnerEnv>
290 implements RunnerApi
291{
292 /**
293 * The JSON protocol the Rust services speak: `POST /rpc/<method>` with the
294 * arguments as the body. The site calls the methods below directly; the
295 * API, which is Rust, reaches them through here. Only bound services can.
296 */
297 async fetch(request: Request): Promise<Response> {
298 const { pathname } = new URL(request.url);
299 if (request.method === "POST" && pathname === "/rpc/run") {
300 const args = (await request.json()) as {
301 actor: User;
302 repo: RepoPath;
303 issue: number;
304 instructions?: string;
305 };
306 return Response.json(
307 await this.run(args.actor, args.repo, args.issue, { instructions: args.instructions }),
308 );
309 }
310 if (request.method === "POST" && pathname === "/rpc/plan") {
311 const args = (await request.json()) as { actor: User; repo: RepoPath; brief: string };
312 return Response.json(await this.plan(args.actor, args.repo, args.brief));
313 }
314 if (request.method === "POST" && pathname === "/rpc/apply_plan") {
315 const args = (await request.json()) as {
316 actor: User;
317 repo: RepoPath;
318 planId: string;
319 assign?: boolean;
320 keep?: number[];
321 };
322 return Response.json(
323 await this.applyPlan(args.actor, args.repo, args.planId, {
324 assign: args.assign,
325 keep: args.keep,
326 }),
327 );
328 }
329 return new Response("Not found\n", { status: 404 });
330 }
331
332 /**
333 * What a sandbox needs to reach the model routed for `task`, having
334 * opened the run the repository's workspace will be charged for. Refused
335 * when that workspace has no credit.
336 */
337 private async modelEnv(
338 task: AgentTask,
339 repo: RepoPath,
340 pull: number,
341 ): Promise<Result<Record<string, string>>> {
342 const routes: AgentRoutes = JSON.parse(this.env.AGENT_ROUTES);
343 const ticket = await billingClient(this.env.BILLING).startRun({
344 workspace: repo.namespace,
345 repo,
346 number: pull,
347 task,
348 model: routes[task].modelName,
349 });
350 if (!ticket.ok) return ticket;
351 const vars = modelEnv(this.env, routes, task, {
352 repo: `${repo.namespace}/${repo.name}`,
353 pull,
354 });
355 if (ticket.value) {
356 // How the sandbox says what the run cost. Kept from the agent.
357 vars.BILLING_RUN = ticket.value.runId;
358 vars.BILLING_TOKEN = ticket.value.token;
359 }
360 return ok(vars);
361 }
362
363 /** The same, for a step g1t takes by itself: a refusal stops the step. */
364 private async modelEnvOrThrow(
365 task: AgentTask,
366 repo: RepoPath,
367 pull: number,
368 ): Promise<Record<string, string>> {
369 const vars = await this.modelEnv(task, repo, pull);
370 if (!vars.ok) throw new Error(vars.error.message);
371 return vars.value;
372 }
373
374 /**
375 * Whether sandboxes may be started on this person's say-so. Where
376 * workspaces pay with real money, anyone's. Until then g1t is paying, or
377 * the cards are pretend, so only the people listed.
378 */
379 private async enabledFor(username: string): Promise<boolean> {
380 const billing = await billingClient(this.env.BILLING).status();
381 if (billing.enabled && billing.live) return true;
382 return this.env.HOSTED_AGENT_USERS.split(",")
383 .map((name) => name.trim())
384 .includes(username);
385 }
386
387 private async allowed(viewer: Viewer): Promise<boolean> {
388 if (!viewer || !canReachModel(this.env)) return false;
389 return this.enabledFor(viewer.username);
390 }
391
392 /**
393 * Events from the bus. Each one that could change what a pull request
394 * needs next moves it along: checks when it becomes ready or its head
395 * moves, then whatever the lifecycle says once those have nothing to do.
396 */
397 async queue(batch: MessageBatch<G1tEvent>): Promise<void> {
398 for (const message of batch.messages) {
399 const event = message.body;
400 switch (event.type) {
401 // A pull request opened from a branch is ready from the start; one
402 // opened as a draft is refused until it is marked ready.
403 case "pull.opened":
404 case "pull.ready":
405 case "pull.updated":
406 if (!(await this.startChecks(event.data.pullId))) {
407 await this.advance(event.data.pullId);
408 }
409 // An agent that has finished its change leaves room for another.
410 if (event.type === "pull.ready") await this.startReady(event.data.repoId);
411 break;
412 case "checks.completed":
413 case "review.completed":
414 await this.advance(event.data.pullId);
415 break;
416 // Something joined, left or landed: test the next batch if none is.
417 case "queue.changed":
418 await this.buildQueue(event.data.repoId);
419 break;
420 // A person approved or asked for changes: one may let it merge,
421 // the other sends the agent back.
422 case "comment.created":
423 if (event.data.pullId && event.data.verdict) await this.advance(event.data.pullId);
424 break;
425 // Someone merged a pull request that is behind: bring it up to
426 // date, and the work service lands it when the push arrives.
427 case "pull.merge_requested":
428 await this.catchUpForMerge(event.data.pullId);
429 break;
430 // The branch the others would land on has moved.
431 case "pull.merged":
432 await this.advanceAll(event.data.repoId);
433 break;
434 // Something an issue was waiting on has finished, or an agent has
435 // stopped and left room for another.
436 case "issue.closed":
437 case "pull.closed":
438 await this.startReady(event.data.repoId);
439 break;
440 }
441 message.ack();
442 }
443 }
444
445 /** A sweep, for steps whose trigger was missed or whose sandbox died. */
446 async scheduled(): Promise<void> {
447 await this.advanceAll();
448 await this.startReady();
449 }
450
451 /**
452 * Puts a g1t agent on each issue that was waiting for one and can now
453 * have it: nothing it depends on is still open, and its repository has
454 * room. One that cannot be started goes back in the queue.
455 */
456 private async startReady(repoId?: string): Promise<void> {
457 const work = workClient(this.env.WORK);
458 for (const issue of await work.readyIssues(repoId)) {
459 const started = await this.run(issue.actor, issue.repo, issue.number).catch(
460 (error: unknown) => fail("conflict", String(error)),
461 );
462 if (!started.ok) await work.queueIssue(issue.actor, issue.repo, issue.number, true);
463 }
464 }
465
466 private async advanceAll(repoId?: string): Promise<void> {
467 const pulls = await workClient(this.env.WORK).managedPulls(repoId);
468 for (const pullId of pulls) await this.advance(pullId);
469 }
470
471 /**
472 * Takes the next step for a pull request g1t is seeing through, if it is
473 * g1t's turn. The work service decides and claims the step, so calling
474 * this twice starts nothing twice.
475 */
476 private async advance(pullId: string): Promise<void> {
477 const work = workClient(this.env.WORK);
478 const next = await work.advance(pullId);
479 if (next.action === "none") return;
480 const { job } = next;
481 try {
482 if (!canReachModel(this.env) || !(await this.enabledFor(job.author.username))) {
483 throw new Error("g1t agents are not enabled for this pull request's author.");
484 }
485 if (next.action === "review") {
486 const started = await this.startReview(pullId);
487 if (!started.ok) throw new Error(started.error.message);
488 } else if (next.action === "revise") {
489 await this.startRevision(job);
490 } else {
491 await this.startCatchUp(job);
492 }
493 } catch (error) {
494 // Stop, and say so on the pull request, instead of trying forever.
495 await work.stall(
496 pullId,
497 `g1t could not start the next step: ${error instanceof Error ? error.message : String(error)}`,
498 );
499 }
500 }
501
502 /** Brings a pull request up to date because a merge is waiting on it. */
503 private async catchUpForMerge(pullId: string): Promise<void> {
504 const work = workClient(this.env.WORK);
505 const job = await work.catchUpJob(pullId);
506 if (!job) return;
507 try {
508 if (!canReachModel(this.env)) throw new Error("g1t agents are not set up.");
509 await this.startCatchUp(job);
510 } catch (error) {
511 await work.stall(
512 pullId,
513 `g1t could not bring this up to date: ${error instanceof Error ? error.message : String(error)}`,
514 );
515 }
516 }
517
518 private async startCatchUp(job: LifecycleJob): Promise<void> {
519 await this.startUpdate({
520 actor: job.author,
521 repo: job.repo,
522 number: job.number,
523 remote: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
524 branch: job.branch ?? job.defaultBranch,
525 defaultBranch: job.defaultBranch,
526 about: [
527 job.title,
528 job.description,
529 job.issue && `Issue #${job.issue.number}: ${job.issue.title}\n\n${job.issue.body}`,
530 ],
531 pullId: job.pullId,
532 });
533 }
534
535 /**
536 * What else is in progress in `repo` besides pull request `number`, told
537 * to the agent working on it and noted in its session.
538 */
539 private async inFlight(actor: User, repo: RepoPath, number: number): Promise<string | null> {
540 const work = workClient(this.env.WORK);
541 const listed = await work.listPulls(repo, actor, "open");
542 if (!listed.ok) return null;
543 const mine = new Set(listed.value.find((pull) => pull.number === number)?.files.map((file) => file.path) ?? []);
544 const others = listed.value.filter((pull) => pull.number !== number);
545 const { prompt, note } = describeInFlight(others, mine);
546 if (note) await work.appendSession(actor, repo, number, [{ kind: "note", text: note }]);
547 return prompt;
548 }
549
550 /**
551 * Starts the next batch of a repository's merge queue, if it has one
552 * ready: a sandbox per entry, all at once, each building the default
553 * branch with that entry and everything ahead of it.
554 */
555 private async buildQueue(repoId: string): Promise<void> {
556 const work = workClient(this.env.WORK);
557 const jobs = await work.queueBuild(repoId);
558 // A state whose sandbox could not start fails at once, rather than
559 // holding the queue until it times out.
560 await Promise.all(
561 jobs.map((job) =>
562 this.startQueueRun(job).catch((error: unknown) =>
563 work.failQueue(job.entryId, job.token, `Its sandbox could not start: ${String(error)}`),
564 ),
565 ),
566 );
567 }
568
569 private async startQueueRun(job: QueueJob): Promise<void> {
570 // To read the changes and push the tested state, as a member.
571 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
572 job.actor,
573 `Merge queue for ${job.repo.namespace}/${job.repo.name}`,
574 CHECKS_TOKEN_TTL_SECONDS,
575 );
576 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
577 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`queue-${job.entryId}-${job.baseCommit}`));
578 await sandbox.run({
579 kind: "queue",
580 entryId: job.entryId,
581 token: job.token,
582 envVars: {
583 MODE: "queue",
584 G1T_API: "https://api.g1t.sh",
585 QUEUE_ENTRY: job.entryId,
586 QUEUE_TOKEN: job.token,
587 G1T_USER: job.actor.username,
588 G1T_TOKEN: token,
589 BASE_REMOTE: remote(job.repo),
590 BASE_COMMIT: job.baseCommit,
591 QUEUE_BRANCH: job.branch,
592 STACK: JSON.stringify(
593 job.stack.map((item) => ({
594 number: item.number,
595 title: item.title,
596 remote: remote(item.source),
597 branch: item.branch,
598 commit: item.commit,
599 })),
600 ),
601 CHECKS: JSON.stringify(job.checks),
602 CONTRACT_CHECKS: JSON.stringify(job.contractChecks),
603 },
604 });
605 }
606
607 /** What people have said on pull request `number`, told to agents working on it. */
608 private async peopleSaid(actor: User, repo: RepoPath, number: number): Promise<string | null> {
609 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
610 return found.ok ? describePeopleSaid(found.value.comments) : null;
611 }
612
613 /** A token for g1t's own tools, for an agent working for `actor` in `repo`. */
614 private async agentToken(actor: User, repo: RepoPath): Promise<string> {
615 const { token } = await identityClient(this.env.IDENTITY).createAgentToken(
616 actor,
617 { repo, operations: AGENT_OPERATIONS },
618 TOKEN_TTL_SECONDS,
619 );
620 return token;
621 }
622
623 private async startRevision(job: LifecycleJob): Promise<void> {
624 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
625 job.author,
626 `g1t agent revising ${job.repo.namespace}/${job.repo.name}#${job.number}`,
627 TOKEN_TTL_SECONDS,
628 );
629 const sandbox = this.env.SANDBOX.get(
630 this.env.SANDBOX.idFromName(`revise-${job.pullId}-${job.round}`),
631 );
632 await sandbox.run({
633 kind: "revise",
634 pullId: job.pullId,
635 envVars: {
636 MODE: "revise",
637 G1T_API: "https://api.g1t.sh",
638 G1T_TOKEN: token,
639 G1T_USER: job.author.username,
640 G1T_REPO: `${job.repo.namespace}/${job.repo.name}`,
641 PULL_NUMBER: String(job.number),
642 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
643 COMMIT_MESSAGE: `Address feedback on #${job.number}`,
644 G1T_AGENT_TOKEN: await this.agentToken(job.author, job.repo),
645 // Revised from where the branch it will land on is now.
646 UPSTREAM_REMOTE: `https://g1t.sh/${job.repo.namespace}/${job.repo.name}.git`,
647 UPSTREAM_BRANCH: job.defaultBranch,
648 PROMPT: buildRevisionPrompt(
649 job,
650 await this.inFlight(job.author, job.repo, job.number),
651 await this.peopleSaid(job.author, job.repo, job.number),
652 ),
653 ...(await this.modelEnvOrThrow("implement", job.repo, job.number)),
654 },
655 });
656 }
657
658 /**
659 * Runs a pull request's acceptance checks in a sandbox of its own. Does
660 * nothing when there is nothing to run.
661 */
662 private async startChecks(pullId: string): Promise<boolean> {
663 const work = workClient(this.env.WORK);
664 const started = await work.startChecks(pullId);
665 if (!started.ok) return false;
666 const job: CheckJob = started.value;
667 // Checks are commands one person wrote, run against code another
668 // pushed, on g1t's machines. In the preview they run only when one of
669 // the two is someone sandboxes are enabled for.
670 if (
671 !(await this.enabledFor(job.requestedBy)) &&
672 !(await this.enabledFor(job.author.username))
673 ) {
674 await work.reportChecks(job.runId, job.token, { skip: true });
675 return false;
676 }
677 // To read the commit, which may be private, as the one who pushed it.
678 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
679 job.author,
680 `Checks on ${job.repo.namespace}/${job.repo.name}#${job.number}`,
681 CHECKS_TOKEN_TTL_SECONDS,
682 );
683 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.runId));
684 await sandbox.run({
685 kind: "checks",
686 runId: job.runId,
687 token: job.token,
688 envVars: {
689 MODE: "checks",
690 G1T_API: "https://api.g1t.sh",
691 CHECK_RUN: job.runId,
692 CHECK_TOKEN: job.token,
693 G1T_USER: job.author.username,
694 G1T_TOKEN: token,
695 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
696 GIT_COMMIT: job.commit,
697 CHECKS: JSON.stringify(job.commands),
698 },
699 });
700 return true;
701 }
702
703 /**
704 * A refusal if `actor` may not put g1t agents to work on `repo`: agents
705 * are not enabled for them, or the work would be charged to a workspace
706 * they do not belong to or that has no credit.
707 */
708 private async refusal(actor: User, repo: RepoPath): Promise<Result<never> | null> {
709 if (!(await this.allowed(actor))) {
710 return fail("forbidden", "g1t agents are not enabled for your account.");
711 }
712 const billing = billingClient(this.env.BILLING);
713 if (!(await billing.status()).enabled) return null;
714 const member = (actor.workspaces ?? []).some(
715 (membership) => membership.slug === repo.namespace.toLowerCase(),
716 );
717 if (!member) {
718 return fail(
719 "forbidden",
720 `Agents are charged to the ${repo.namespace} workspace, so only its members can put them to work here.`,
721 );
722 }
723 const credit = await billing.canStart(repo.namespace);
724 return credit.ok ? null : credit;
725 }
726
727 async update(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> {
728 const refused = await this.refusal(actor, repo);
729 if (refused) return refused;
730 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
731 if (!found.ok) return found;
732 const { pull, issue, behind } = found.value;
733 if (pull.status !== "draft" && pull.status !== "open") {
734 return fail("conflict", `This pull request is already ${pull.status}.`);
735 }
736 if (!behind) return fail("conflict", "This pull request is already up to date.");
737 // The result is pushed as the person asking, so they must be able to
738 // push there: a fork takes pushes only from whoever opened it.
739 const member = (actor.workspaces ?? []).some(
740 (membership) => membership.slug === repo.namespace,
741 );
742 if (pull.fork ? pull.author.id !== actor.id : !member) {
743 return fail(
744 "forbidden",
745 pull.fork
746 ? "Only whoever opened this pull request can update it."
747 : "Only members of the workspace can update this pull request.",
748 );
749 }
750 const defaultBranch = await this.defaultBranch(repo, actor);
751 await this.startUpdate({
752 actor,
753 repo,
754 number,
755 remote: pull.fork
756 ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`
757 : `https://g1t.sh/${repo.namespace}/${repo.name}.git`,
758 branch: pull.branch ?? defaultBranch,
759 defaultBranch,
760 about: [pull.title, pull.body, issue && `Issue #${issue.number}: ${issue.title}\n\n${issue.body}`],
761 });
762 return ok(true);
763 }
764
765 /** Starts a sandbox that merges the default branch into a pull request. */
766 private async startUpdate(update: {
767 /** Who the result is pushed as. */
768 actor: User;
769 repo: RepoPath;
770 number: number;
771 /** The pull request's source, and the branch of it holding the change. */
772 remote: string;
773 branch: string;
774 defaultBranch: string;
775 /** What the pull request is for, given to the agent on a conflict. */
776 about: (string | null | undefined | false)[];
777 /** Set when g1t started this itself. */
778 pullId?: string;
779 }): Promise<void> {
780 const { actor, repo, number } = update;
781 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
782 actor,
783 `Catching up ${repo.namespace}/${repo.name}#${number}`,
784 TOKEN_TTL_SECONDS,
785 );
786 const sandbox = this.env.SANDBOX.get(
787 this.env.SANDBOX.idFromName(`update-${repo.namespace}-${repo.name}-${number}-${Date.now()}`),
788 );
789 await sandbox.run({
790 kind: "update",
791 pullId: update.pullId,
792 envVars: {
793 MODE: "update",
794 G1T_API: "https://api.g1t.sh",
795 G1T_TOKEN: token,
796 G1T_USER: actor.username,
797 G1T_REPO: `${repo.namespace}/${repo.name}`,
798 PULL_NUMBER: String(number),
799 GIT_REMOTE: update.remote,
800 GIT_BRANCH: update.branch,
801 UPSTREAM_REMOTE: `https://g1t.sh/${repo.namespace}/${repo.name}.git`,
802 UPSTREAM_BRANCH: update.defaultBranch,
803 PROMPT: update.about.filter(Boolean).join("\n\n"),
804 ...(await this.modelEnvOrThrow("update", repo, number)),
805 },
806 });
807 }
808
809 async review(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> {
810 const refused = await this.refusal(actor, repo);
811 if (refused) return refused;
812 // Whoever can see a pull request can ask for it to be reviewed.
813 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
814 if (!found.ok) return found;
815 if (found.value.reviewPending) {
816 return fail("conflict", "A g1t agent is already reviewing this pull request.");
817 }
818 return this.startReview(found.value.pull.id);
819 }
820
821 /** Starts a sandbox in which a g1t agent reviews a pull request. */
822 private async startReview(pullId: string): Promise<Result<boolean>> {
823 const started = await workClient(this.env.WORK).startReview(pullId);
824 if (!started.ok) return started;
825 const job = started.value;
826 const { repo, number } = job;
827 // To read the commit, which may be private, as the one who pushed it.
828 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
829 job.author,
830 `Review of ${repo.namespace}/${repo.name}#${number}`,
831 CHECKS_TOKEN_TTL_SECONDS,
832 );
833 const about = [
834 `Pull request #${job.number}: ${job.title}`,
835 job.description,
836 job.issue &&
837 `It is for issue #${job.issue.number}: ${job.issue.title}\n\n${job.issue.body}`,
838 job.issue?.checks.length &&
839 `The issue's acceptance checks: ${job.issue.checks.join("; ")}`,
840 await this.peopleSaid(job.author, repo, number),
841 ];
842 const model = await this.modelEnv("review", repo, number);
843 if (!model.ok) {
844 await workClient(this.env.WORK).failReview(job.runId, job.token, model.error.message);
845 return model;
846 }
847 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.runId));
848 await sandbox.run({
849 kind: "review",
850 runId: job.runId,
851 token: job.token,
852 envVars: {
853 MODE: "review",
854 G1T_API: "https://api.g1t.sh",
855 REVIEW_RUN: job.runId,
856 REVIEW_TOKEN: job.token,
857 G1T_USER: job.author.username,
858 G1T_TOKEN: token,
859 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
860 GIT_COMMIT: job.commit,
861 UPSTREAM_REMOTE: `https://g1t.sh/${job.repo.namespace}/${job.repo.name}.git`,
862 UPSTREAM_BRANCH: job.defaultBranch,
863 PROMPT: about.filter(Boolean).join("\n\n"),
864 ...model.value,
865 },
866 });
867 return ok(true);
868 }
869
870 private async defaultBranch(repo: RepoPath, viewer: Viewer): Promise<string> {
871 const found = await reposClient(this.env.REPOS).get(repo, viewer);
872 return found.ok ? found.value.defaultBranch : "main";
873 }
874
875 async recheck(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> {
876 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
877 if (!found.ok) return found;
878 const { pull } = found.value;
879 const member = (actor.workspaces ?? []).some(
880 (membership) => membership.slug === repo.namespace,
881 );
882 if (!member && pull.author.id !== actor.id) {
883 return fail(
884 "forbidden",
885 "Only whoever opened a pull request, or a member of the workspace, can run its checks.",
886 );
887 }
888 return (await this.startChecks(pull.id))
889 ? ok(true)
890 : fail("conflict", "There are no checks to run for this pull request right now.");
891 }
892
893 async plan(actor: User, repo: RepoPath, brief: string): Promise<Result<{ planId: string }>> {
894 const refused = await this.refusal(actor, repo);
895 if (refused) return refused;
896 const work = workClient(this.env.WORK);
897 const started = await work.startPlan(actor, repo, brief);
898 if (!started.ok) return started;
899 const job = started.value;
900 const model = await this.modelEnv("plan", repo, 0);
901 if (!model.ok) {
902 await work.failPlan(job.planId, job.token, model.error.message);
903 return model;
904 }
905 // To read the repository, which may be private, as the one planning.
906 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
907 actor,
908 `Planning for ${repo.namespace}/${repo.name}`,
909 CHECKS_TOKEN_TTL_SECONDS,
910 );
911 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.planId));
912 await sandbox.run({
913 kind: "plan",
914 planId: job.planId,
915 token: job.token,
916 envVars: {
917 MODE: "plan",
918 G1T_API: "https://api.g1t.sh",
919 PLAN_ID: job.planId,
920 PLAN_TOKEN: job.token,
921 G1T_USER: actor.username,
922 G1T_TOKEN: token,
923 GIT_REMOTE: `https://g1t.sh/${repo.namespace}/${repo.name}.git`,
924 PROMPT: job.brief,
925 ...model.value,
926 },
927 });
928 return ok({ planId: job.planId });
929 }
930
931 async applyPlan(
932 actor: User,
933 repo: RepoPath,
934 planId: string,
935 options: { assign?: boolean; keep?: number[] } = {},
936 ): Promise<Result<Plan>> {
937 if (options.assign) {
938 const refused = await this.refusal(actor, repo);
939 if (refused) return refused;
940 }
941 const applied = await workClient(this.env.WORK).applyPlan(actor, repo, planId, options);
942 if (!applied.ok) return applied;
943 // Agents start on everything that depends on nothing; the rest follow
944 // as what they depend on merges.
945 if (options.assign) await this.startReady(applied.value.repoId);
946 return applied;
947 }
948
949 async enabled(viewer: Viewer): Promise<boolean> {
950 return await this.allowed(viewer);
951 }
952
953 async run(
954 actor: User,
955 repo: RepoPath,
956 issueNumber: number,
957 input: RunHostedInput = {},
958 ): Promise<Result<Pull>> {
959 const refused = await this.refusal(actor, repo);
960 if (refused) return refused;
961 const work = workClient(this.env.WORK);
962
963 const found = await work.getIssue(repo, issueNumber, actor);
964 if (!found.ok) return found;
965 const { issue } = found.value;
966
967 const opened = await work.openPull(actor, repo, {
968 issue: issue.number,
969 agent: AGENT,
970 runtime: "hosted",
971 });
972 if (!opened.ok) return opened;
973 const pull = opened.value;
974 // Opened without a branch, so it has a fork.
975 const fork = pull.fork!;
976
977 const model = await this.modelEnv("implement", repo, pull.number);
978 if (!model.ok) {
979 await work.closePull(actor, repo, pull.number);
980 return model;
981 }
982
983 // The sandbox acts as the person who assigned the issue, through a
984 // token that only lives as long as a run can.
985 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
986 actor,
987 `g1t agent on ${repo.namespace}/${repo.name}#${pull.number}`,
988 TOKEN_TTL_SECONDS,
989 );
990 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(pull.id));
991 await sandbox.run({
992 kind: "agent",
993 actor,
994 repo,
995 number: pull.number,
996 envVars: {
997 G1T_API: "https://api.g1t.sh",
998 G1T_TOKEN: token,
999 G1T_USER: actor.username,
1000 G1T_REPO: `${repo.namespace}/${repo.name}`,
1001 PULL_NUMBER: String(pull.number),
1002 GIT_REMOTE: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
1003 COMMIT_MESSAGE: issue.title,
1004 G1T_AGENT_TOKEN: await this.agentToken(actor, repo),
1005 PROMPT: buildPrompt(
1006 issue,
1007 input.instructions?.trim() ?? "",
1008 await this.inFlight(actor, repo, pull.number),
1009 ),
1010 ...model.value,
1011 },
1012 });
1013 return ok(pull);
1014 }
1015}