g1t/services/runner/src/index.ts

300 lines10,498 bytesCodeBlame

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 intent1import { Container, type StopParams } from "@cloudflare/containers";
2import { WorkerEntrypoint } from "cloudflare:workers";
3
4import {
g1t agents: model menu and optional AI Gateway routing5 type AgentModel,
Acceptance checks in sandboxes, line comments and review verdicts6 type CheckJob,
7 type G1tEvent,
Issues and pull requests replace intents and attempts8 type Issue,
9 type Pull,
10 type RepoPath,
Hosted agents: sandboxes on Cloudflare Containers started from an intent11 type Result,
12 type RunHostedInput,
13 type RunnerApi,
14 type ServiceBinding,
15 type User,
16 type Viewer,
17 fail,
18 identityClient,
19 ok,
Work service in Rust, with RFC 3339 timestamps20 workClient,
Hosted agents: sandboxes on Cloudflare Containers started from an intent21} from "@g1t/contracts";
22
Members can read a private repository's pull request forks23import { type ConfiguredModel, modelEnv } from "./model-env";
24
Hosted agents: sandboxes on Cloudflare Containers started from an intent25export interface RunnerEnv {
26 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
27 IDENTITY: ServiceBinding;
Work service in Rust, with RFC 3339 timestamps28 WORK: ServiceBinding;
Hosted agents: sandboxes on Cloudflare Containers started from an intent29 /** Secret. The model key the hosted agent runs on. */
30 ANTHROPIC_API_KEY?: string;
31 /**
32 * Comma-separated usernames allowed to start hosted agents. Runs spend the
33 * key above, so this stays an allowlist until accounts bring their own.
34 */
35 HOSTED_AGENT_USERS: string;
g1t agents: model menu and optional AI Gateway routing36 /**
Show the model behind each choice; toolchains in the sandbox37 * The models offered, as JSON:
38 * `[{ id, label, description, modelName, model }]`. `modelName` is what
39 * people see; `model` is the identifier sent to the provider.
g1t agents: model menu and optional AI Gateway routing40 */
41 AGENT_MODELS: string;
42 /**
43 * A Cloudflare AI Gateway id. When set, model traffic goes through that
44 * gateway, which is where logging, spend limits, caching and fallback
45 * between providers are configured. Empty sends it to the provider
46 * directly.
47 */
48 AI_GATEWAY_ID: string;
49 CLOUDFLARE_ACCOUNT_ID: string;
50 /** Secret. Needed only if the gateway requires authentication. */
51 AI_GATEWAY_TOKEN?: string;
Hosted agents: sandboxes on Cloudflare Containers started from an intent52}
53
54const MAX_AGENTS_PER_RUN = 5;
55/** A run that takes longer than this has its token expire under it. */
56const TOKEN_TTL_SECONDS = 2 * 60 * 60;
Diffs on attempts; hosted agent presented as the g1t agent57/** How g1t's own agent is labelled. What runs behind it is g1t's choice. */
58const AGENT = "g1t-agent";
Hosted agents: sandboxes on Cloudflare Containers started from an intent59
Acceptance checks in sandboxes, line comments and review verdicts60/**
61 * What a sandbox is doing: an agent working on a pull request as someone,
62 * or a run of acceptance checks.
63 */
64type Run =
65 | { kind: "agent"; actor: User; repo: RepoPath; number: number }
66 | { kind: "checks"; runId: string; token: string };
Issues and pull requests replace intents and attempts67type RunRequest = Run & { envVars: Record<string, string> };
Hosted agents: sandboxes on Cloudflare Containers started from an intent68
Acceptance checks in sandboxes, line comments and review verdicts69/** Long enough to clone, install and test; then the token stops working. */
70const CHECKS_TOKEN_TTL_SECONDS = 45 * 60;
71
Hosted agents: sandboxes on Cloudflare Containers started from an intent72/**
Acceptance checks in sandboxes, line comments and review verdicts73 * One sandbox, for one agent or one run of checks. The image's entrypoint
74 * is the g1t runner, which does the work and exits; this class only starts
75 * it and cleans up if it dies without reporting.
Hosted agents: sandboxes on Cloudflare Containers started from an intent76 */
77export class AttemptSandbox extends Container<RunnerEnv> {
78 sleepAfter = "45m";
79
80 async run(request: RunRequest): Promise<void> {
Issues and pull requests replace intents and attempts81 const { envVars, ...run } = request;
82 await this.ctx.storage.put("run", run);
83 await this.start({ envVars, enableInternet: true });
Hosted agents: sandboxes on Cloudflare Containers started from an intent84 }
85
86 override async onStop({ exitCode }: StopParams): Promise<void> {
87 if (exitCode === 0) return;
Acceptance checks in sandboxes, line comments and review verdicts88 const run = await this.ctx.storage.get<Run>("run");
89 if (!run) return;
90 const work = workClient(this.env.WORK);
91 if (run.kind === "checks") {
92 // Refused harmlessly if the run did report before it stopped.
93 await work.reportChecks(run.runId, run.token, {
94 error: "The sandbox stopped before the checks finished.",
95 });
96 return;
97 }
Issues and pull requests replace intents and attempts98 // The runner closes its own pull request when it fails. This covers a
99 // sandbox that was killed before it could; closing twice is refused
Hosted agents: sandboxes on Cloudflare Containers started from an intent100 // harmlessly.
Acceptance checks in sandboxes, line comments and review verdicts101 await work.closePull(run.actor, run.repo, run.number);
g1t agents: model menu and optional AI Gateway routing102 }
103}
104
Issues and pull requests replace intents and attempts105function buildPrompt(issue: Issue, instructions: string): string {
Hosted agents: sandboxes on Cloudflare Containers started from an intent106 const parts = [
107 "You are a coding agent working in the git repository checked out in the current directory.",
Issues and pull requests replace intents and attempts108 `Issue #${issue.number}: ${issue.title}`,
109 issue.body,
Hosted agents: sandboxes on Cloudflare Containers started from an intent110 ];
Issues and pull requests replace intents and attempts111 if (issue.checks.length > 0) {
Hosted agents: sandboxes on Cloudflare Containers started from an intent112 parts.push(
Issues and pull requests replace intents and attempts113 `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 intent114 );
115 }
116 if (instructions) parts.push(instructions);
117 parts.push(
Members can read a private repository's pull request forks118 "Make the change and keep it focused on the issue. Commit your work with a clear message. Do not push; that is done for you. Finish with a short summary of what you changed and why. It becomes the description of your pull request, so write it for a reviewer and leave out whether anything was committed or pushed.",
Hosted agents: sandboxes on Cloudflare Containers started from an intent119 );
120 return parts.filter(Boolean).join("\n\n");
121}
122
123export default class RunnerService
124 extends WorkerEntrypoint<RunnerEnv>
125 implements RunnerApi
126{
127 /** A Worker must have an event handler; this service is RPC-only. */
128 fetch(): Response {
129 return new Response("Not found\n", { status: 404 });
130 }
131
g1t agents: model menu and optional AI Gateway routing132 private configuredModels(): ConfiguredModel[] {
133 return JSON.parse(this.env.AGENT_MODELS);
134 }
135
Acceptance checks in sandboxes, line comments and review verdicts136 /** Whether sandboxes may be started on this person's say-so. */
137 private enabledFor(username: string): boolean {
138 return this.env.HOSTED_AGENT_USERS.split(",")
139 .map((name) => name.trim())
140 .includes(username);
141 }
142
g1t agents: model menu and optional AI Gateway routing143 private allowed(viewer: Viewer): boolean {
Hosted agents: sandboxes on Cloudflare Containers started from an intent144 if (!viewer || !this.env.ANTHROPIC_API_KEY) return false;
Acceptance checks in sandboxes, line comments and review verdicts145 return this.enabledFor(viewer.username);
146 }
147
148 /** Events from the bus: a pull request was opened, became ready, or moved. */
149 async queue(batch: MessageBatch<G1tEvent>): Promise<void> {
150 for (const message of batch.messages) {
151 const event = message.body;
152 // A pull request opened from a branch is ready from the start; one
153 // opened as a draft is refused below until it is marked ready.
154 if (
155 event.type === "pull.opened" ||
156 event.type === "pull.ready" ||
157 event.type === "pull.updated"
158 ) {
159 await this.startChecks(event.data.pullId);
160 }
161 message.ack();
162 }
163 }
164
165 /**
166 * Runs a pull request's acceptance checks in a sandbox of its own. Does
167 * nothing when there is nothing to run.
168 */
169 private async startChecks(pullId: string): Promise<boolean> {
170 const work = workClient(this.env.WORK);
171 const started = await work.startChecks(pullId);
172 if (!started.ok) return false;
173 const job: CheckJob = started.value;
174 // Checks are commands one person wrote, run against code another
175 // pushed, on g1t's machines. In the preview they run only when one of
176 // the two is someone sandboxes are enabled for.
177 if (!this.enabledFor(job.requestedBy) && !this.enabledFor(job.author.username)) {
178 await work.reportChecks(job.runId, job.token, { skip: true });
179 return false;
180 }
181 // To read the commit, which may be private, as the one who pushed it.
182 const { token } = await identityClient(this.env.IDENTITY).createAccessToken(
183 job.author,
184 `Checks on ${job.repo.namespace}/${job.repo.name}#${job.number}`,
185 CHECKS_TOKEN_TTL_SECONDS,
186 );
187 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(job.runId));
188 await sandbox.run({
189 kind: "checks",
190 runId: job.runId,
191 token: job.token,
192 envVars: {
193 MODE: "checks",
194 G1T_API: "https://api.g1t.sh",
195 CHECK_RUN: job.runId,
196 CHECK_TOKEN: job.token,
197 G1T_USER: job.author.username,
198 G1T_TOKEN: token,
199 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
200 GIT_COMMIT: job.commit,
201 CHECKS: JSON.stringify(job.commands),
202 },
203 });
204 return true;
205 }
206
207 async recheck(actor: User, repo: RepoPath, number: number): Promise<Result<boolean>> {
208 const found = await workClient(this.env.WORK).getPull(repo, number, actor);
209 if (!found.ok) return found;
210 const { pull } = found.value;
211 const member = (actor.workspaces ?? []).some(
212 (membership) => membership.slug === repo.namespace,
213 );
214 if (!member && pull.author.id !== actor.id) {
215 return fail(
216 "forbidden",
217 "Only whoever opened a pull request, or a member of the workspace, can run its checks.",
218 );
219 }
220 return (await this.startChecks(pull.id))
221 ? ok(true)
222 : fail("conflict", "There are no checks to run for this pull request right now.");
Hosted agents: sandboxes on Cloudflare Containers started from an intent223 }
224
g1t agents: model menu and optional AI Gateway routing225 async models(viewer: Viewer): Promise<AgentModel[]> {
226 if (!this.allowed(viewer)) return [];
Show the model behind each choice; toolchains in the sandbox227 return this.configuredModels().map(({ id, label, description, modelName }) => ({
g1t agents: model menu and optional AI Gateway routing228 id,
229 label,
230 description,
Show the model behind each choice; toolchains in the sandbox231 modelName,
g1t agents: model menu and optional AI Gateway routing232 }));
233 }
234
Hosted agents: sandboxes on Cloudflare Containers started from an intent235 async run(
236 actor: User,
Issues and pull requests replace intents and attempts237 repo: RepoPath,
238 issueNumber: number,
Hosted agents: sandboxes on Cloudflare Containers started from an intent239 input: RunHostedInput,
Issues and pull requests replace intents and attempts240 ): Promise<Result<Pull[]>> {
g1t agents: model menu and optional AI Gateway routing241 if (!this.allowed(actor)) {
242 return fail("forbidden", "g1t agents are not enabled for your account.");
Hosted agents: sandboxes on Cloudflare Containers started from an intent243 }
g1t agents: model menu and optional AI Gateway routing244 const models = this.configuredModels();
245 const model = input.model
246 ? models.find((candidate) => candidate.id === input.model)
247 : models[0];
248 if (!model) return fail("invalid", "That model is not available.");
Hosted agents: sandboxes on Cloudflare Containers started from an intent249 const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), MAX_AGENTS_PER_RUN);
250 const identity = identityClient(this.env.IDENTITY);
Work service in Rust, with RFC 3339 timestamps251 const work = workClient(this.env.WORK);
Hosted agents: sandboxes on Cloudflare Containers started from an intent252
Issues and pull requests replace intents and attempts253 const found = await work.getIssue(repo, issueNumber, actor);
254 if (!found.ok) return found;
255 const { issue } = found.value;
256 const prompt = buildPrompt(issue, input.instructions?.trim() ?? "");
257
258 const pulls: Pull[] = [];
Hosted agents: sandboxes on Cloudflare Containers started from an intent259 for (let i = 0; i < count; i++) {
Issues and pull requests replace intents and attempts260 const opened = await work.openPull(actor, repo, {
261 issue: issue.number,
Hosted agents: sandboxes on Cloudflare Containers started from an intent262 agent: AGENT,
263 runtime: "hosted",
264 });
265 // The first failure is the answer; later ones mean some already run.
Issues and pull requests replace intents and attempts266 if (!opened.ok) return pulls.length ? ok(pulls) : opened;
267 const pull = opened.value;
Pull requests from branches268 // Opened without a branch, so it has a fork.
269 const fork = pull.fork!;
Issues and pull requests replace intents and attempts270 pulls.push(pull);
Hosted agents: sandboxes on Cloudflare Containers started from an intent271
272 // The sandbox acts as the person who started it, through a token
273 // that only lives as long as a run can.
274 const { token } = await identity.createAccessToken(
275 actor,
Issues and pull requests replace intents and attempts276 `g1t agent on ${repo.namespace}/${repo.name}#${pull.number}`,
Hosted agents: sandboxes on Cloudflare Containers started from an intent277 TOKEN_TTL_SECONDS,
278 );
Issues and pull requests replace intents and attempts279 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(pull.id));
Hosted agents: sandboxes on Cloudflare Containers started from an intent280 await sandbox.run({
Acceptance checks in sandboxes, line comments and review verdicts281 kind: "agent",
Hosted agents: sandboxes on Cloudflare Containers started from an intent282 actor,
Issues and pull requests replace intents and attempts283 repo,
284 number: pull.number,
Hosted agents: sandboxes on Cloudflare Containers started from an intent285 envVars: {
286 G1T_API: "https://api.g1t.sh",
287 G1T_TOKEN: token,
288 G1T_USER: actor.username,
Issues and pull requests replace intents and attempts289 G1T_REPO: `${repo.namespace}/${repo.name}`,
290 PULL_NUMBER: String(pull.number),
Pull requests from branches291 GIT_REMOTE: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
Issues and pull requests replace intents and attempts292 COMMIT_MESSAGE: issue.title,
293 PROMPT: prompt,
g1t agents: model menu and optional AI Gateway routing294 ...modelEnv(this.env, model),
Hosted agents: sandboxes on Cloudflare Containers started from an intent295 },
296 });
297 }
Issues and pull requests replace intents and attempts298 return ok(pulls);
Hosted agents: sandboxes on Cloudflare Containers started from an intent299 }
300}