pr_01m47d15m3e54sn21z27rpy5n9/services/runner/src/index.ts

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