pr_01m47d24b0e6n91zwymwxg0vpx/services/runner/src/index.ts
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 1 | import { Container, type StopParams } from "@cloudflare/containers"; |
| 2 | import { WorkerEntrypoint } from "cloudflare:workers"; | |
| 3 | ||
| 4 | import { | |
| Acceptance checks in sandboxes, line comments and review verdicts | 5 | type CheckJob, |
| 6 | type G1tEvent, | |
| Issues and pull requests replace intents and attempts | 7 | type Issue, |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 8 | type LifecycleJob, |
| 9 | type Plan, | |
| 10 | type Comment, | |
| Issues and pull requests replace intents and attempts | 11 | type Pull, |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 12 | type QueueJob, |
| Issues and pull requests replace intents and attempts | 13 | type RepoPath, |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 14 | type Result, |
| 15 | type RunHostedInput, | |
| 16 | type RunnerApi, | |
| 17 | type ServiceBinding, | |
| 18 | type User, | |
| 19 | type Viewer, | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 20 | billingClient, |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 21 | fail, |
| 22 | identityClient, | |
| 23 | ok, | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 24 | reposClient, |
| Work service in Rust, with RFC 3339 timestamps | 25 | workClient, |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 26 | } from "@g1t/contracts"; |
| 27 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 28 | import { type AgentRoutes, type AgentTask, canReachModel, modelEnv } from "./model-env"; |
| Members can read a private repository's pull request forks | 29 | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 30 | export interface RunnerEnv { |
| 31 | SANDBOX: DurableObjectNamespace<AttemptSandbox>; | |
| 32 | IDENTITY: ServiceBinding; | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 33 | REPOS: ServiceBinding; |
| Work service in Rust, with RFC 3339 timestamps | 34 | WORK: ServiceBinding; |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | */ | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 40 | ANTHROPIC_API_KEY?: string; |
| 41 | /** | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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. | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 45 | */ |
| 46 | HOSTED_AGENT_USERS: string; | |
| g1t agents: model menu and optional AI Gateway routing | 47 | /** |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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. | |
| g1t agents: model menu and optional AI Gateway routing | 51 | */ |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 52 | AGENT_ROUTES: string; |
| g1t agents: model menu and optional AI Gateway routing | 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; | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 61 | /** Secret. Authenticates to the gateway, if it requires it. */ |
| g1t agents: model menu and optional AI Gateway routing | 62 | AI_GATEWAY_TOKEN?: string; |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 63 | } |
| 64 | ||
| 65 | /** A run that takes longer than this has its token expire under it. */ | |
| 66 | const TOKEN_TTL_SECONDS = 2 * 60 * 60; | |
| Diffs on attempts; hosted agent presented as the g1t agent | 67 | /** How g1t's own agent is labelled. What runs behind it is g1t's choice. */ |
| 68 | const AGENT = "g1t-agent"; | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 69 | |
| Acceptance checks in sandboxes, line comments and review verdicts | 70 | /** |
| 71 | * What a sandbox is doing: an agent working on a pull request as someone, | |
| 72 | * or a run of acceptance checks. | |
| 73 | */ | |
| 74 | type Run = | |
| 75 | | { kind: "agent"; actor: User; repo: RepoPath; number: number } | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 }; | |
| Issues and pull requests replace intents and attempts | 90 | type RunRequest = Run & { envVars: Record<string, string> }; |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 91 | |
| Acceptance checks in sandboxes, line comments and review verdicts | 92 | /** Long enough to clone, install and test; then the token stops working. */ |
| 93 | const CHECKS_TOKEN_TTL_SECONDS = 45 * 60; | |
| 94 | ||
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 95 | /** |
| Acceptance checks in sandboxes, line comments and review verdicts | 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. | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 99 | */ |
| 100 | export class AttemptSandbox extends Container<RunnerEnv> { | |
| 101 | sleepAfter = "45m"; | |
| 102 | ||
| 103 | async run(request: RunRequest): Promise<void> { | |
| Issues and pull requests replace intents and attempts | 104 | const { envVars, ...run } = request; |
| 105 | await this.ctx.storage.put("run", run); | |
| 106 | await this.start({ envVars, enableInternet: true }); | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 107 | } |
| 108 | ||
| 109 | override async onStop({ exitCode }: StopParams): Promise<void> { | |
| 110 | if (exitCode === 0) return; | |
| Acceptance checks in sandboxes, line comments and review verdicts | 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 | } | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | } | |
| Issues and pull requests replace intents and attempts | 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 | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 148 | // harmlessly. |
| Acceptance checks in sandboxes, line comments and review verdicts | 149 | await work.closePull(run.actor, run.repo, run.number); |
| g1t agents: model menu and optional AI Gateway routing | 150 | } |
| 151 | } | |
| 152 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 153 | /** How many other pull requests an agent is told about. */ |
| 154 | const MAX_IN_FLIGHT = 12; | |
| 155 | /** How many of each one's files are named. */ | |
| 156 | const 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 | */ | |
| 164 | type InFlight = { prompt: string | null; note: string | null }; | |
| 165 | ||
| 166 | function 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. */ | |
| 197 | const 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", | |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 210 | // Messages people send it while it works, picked up between steps. |
| 211 | "take_messages", | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 212 | ]; |
| 213 | ||
| 214 | /** How an agent is told to use g1t's tools to work with the others. */ | |
| 215 | const 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. */ | |
| 219 | const MAX_PEOPLE_SAID_CHARS = 6000; | |
| 220 | /** Accounts that are g1t itself, not people. */ | |
| 221 | const 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 | */ | |
| 227 | function 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. */ | |
| 250 | function buildRevisionPrompt(job: LifecycleJob, inFlight: string | null, peopleSaid: string | null): string { | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 251 | const parts = [ |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | ||
| 268 | function buildPrompt(issue: Issue, instructions: string, inFlight: string | null): string { | |
| 269 | const parts = [ | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 270 | "You are a coding agent working in the git repository checked out in the current directory.", |
| Issues and pull requests replace intents and attempts | 271 | `Issue #${issue.number}: ${issue.title}`, |
| 272 | issue.body, | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 273 | ]; |
| Issues and pull requests replace intents and attempts | 274 | if (issue.checks.length > 0) { |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 275 | parts.push( |
| Issues and pull requests replace intents and attempts | 276 | `These commands must pass when you are done. Run them if the tools are installed:\n${issue.checks.map((check) => `- ${check}`).join("\n")}`, |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 277 | ); |
| 278 | } | |
| 279 | if (instructions) parts.push(instructions); | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 280 | if (inFlight) parts.push(inFlight); |
| 281 | parts.push(WORKING_WITH_OTHERS); | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 282 | parts.push( |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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.", |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 284 | ); |
| 285 | return parts.filter(Boolean).join("\n\n"); | |
| 286 | } | |
| 287 | ||
| 288 | export default class RunnerService | |
| 289 | extends WorkerEntrypoint<RunnerEnv> | |
| 290 | implements RunnerApi | |
| 291 | { | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | } | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 329 | return new Response("Not found\n", { status: 404 }); |
| 330 | } | |
| 331 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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; | |
| g1t agents: model menu and optional AI Gateway routing | 372 | } |
| 373 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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; | |
| Acceptance checks in sandboxes, line comments and review verdicts | 382 | return this.env.HOSTED_AGENT_USERS.split(",") |
| 383 | .map((name) => name.trim()) | |
| 384 | .includes(username); | |
| 385 | } | |
| 386 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 387 | private async allowed(viewer: Viewer): Promise<boolean> { |
| 388 | if (!viewer || !canReachModel(this.env)) return false; | |
| Acceptance checks in sandboxes, line comments and review verdicts | 389 | return this.enabledFor(viewer.username); |
| 390 | } | |
| 391 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | */ | |
| Acceptance checks in sandboxes, line comments and review verdicts | 397 | async queue(batch: MessageBatch<G1tEvent>): Promise<void> { |
| 398 | for (const message of batch.messages) { | |
| 399 | const event = message.body; | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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; | |
| Acceptance checks in sandboxes, line comments and review verdicts | 440 | } |
| 441 | message.ack(); | |
| 442 | } | |
| 443 | } | |
| 444 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | ||
| Acceptance checks in sandboxes, line comments and review verdicts | 550 | /** |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | /** | |
| Acceptance checks in sandboxes, line comments and review verdicts | 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. | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 670 | if ( |
| 671 | !(await this.enabledFor(job.requestedBy)) && | |
| 672 | !(await this.enabledFor(job.author.username)) | |
| 673 | ) { | |
| Acceptance checks in sandboxes, line comments and review verdicts | 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 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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 | ||
| Acceptance checks in sandboxes, line comments and review verdicts | 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."); | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 891 | } |
| 892 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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); | |
| g1t agents: model menu and optional AI Gateway routing | 951 | } |
| 952 | ||
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 953 | async run( |
| 954 | actor: User, | |
| Issues and pull requests replace intents and attempts | 955 | repo: RepoPath, |
| 956 | issueNumber: number, | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 957 | input: RunHostedInput = {}, |
| 958 | ): Promise<Result<Pull>> { | |
| 959 | const refused = await this.refusal(actor, repo); | |
| 960 | if (refused) return refused; | |
| Work service in Rust, with RFC 3339 timestamps | 961 | const work = workClient(this.env.WORK); |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 962 | |
| Issues and pull requests replace intents and attempts | 963 | const found = await work.getIssue(repo, issueNumber, actor); |
| 964 | if (!found.ok) return found; | |
| 965 | const { issue } = found.value; | |
| 966 | ||
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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!; | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 976 | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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; | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 981 | } |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 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); | |
| Hosted agents: sandboxes on Cloudflare Containers started from an intent | 1014 | } |
| 1015 | } |