g1t/services/runner/src/index.ts

198 lines6,816 bytesCodeBlame
1import { Container, type StopParams } from "@cloudflare/containers";
2import { WorkerEntrypoint } from "cloudflare:workers";
3
4import {
5 type AgentModel,
6 type Issue,
7 type Pull,
8 type RepoPath,
9 type Result,
10 type RunHostedInput,
11 type RunnerApi,
12 type ServiceBinding,
13 type User,
14 type Viewer,
15 fail,
16 identityClient,
17 ok,
18 workClient,
19} from "@g1t/contracts";
20
21import { type ConfiguredModel, modelEnv } from "./model-env";
22
23export interface RunnerEnv {
24 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
25 IDENTITY: ServiceBinding;
26 WORK: ServiceBinding;
27 /** Secret. The model key the hosted agent runs on. */
28 ANTHROPIC_API_KEY?: string;
29 /**
30 * Comma-separated usernames allowed to start hosted agents. Runs spend the
31 * key above, so this stays an allowlist until accounts bring their own.
32 */
33 HOSTED_AGENT_USERS: string;
34 /**
35 * The models offered, as JSON:
36 * `[{ id, label, description, modelName, model }]`. `modelName` is what
37 * people see; `model` is the identifier sent to the provider.
38 */
39 AGENT_MODELS: string;
40 /**
41 * A Cloudflare AI Gateway id. When set, model traffic goes through that
42 * gateway, which is where logging, spend limits, caching and fallback
43 * between providers are configured. Empty sends it to the provider
44 * directly.
45 */
46 AI_GATEWAY_ID: string;
47 CLOUDFLARE_ACCOUNT_ID: string;
48 /** Secret. Needed only if the gateway requires authentication. */
49 AI_GATEWAY_TOKEN?: string;
50}
51
52const MAX_AGENTS_PER_RUN = 5;
53/** A run that takes longer than this has its token expire under it. */
54const TOKEN_TTL_SECONDS = 2 * 60 * 60;
55/** How g1t's own agent is labelled. What runs behind it is g1t's choice. */
56const AGENT = "g1t-agent";
57
58/** Which pull request a sandbox is working on, and as whom. */
59type Run = { actor: User; repo: RepoPath; number: number };
60type RunRequest = Run & { envVars: Record<string, string> };
61
62/**
63 * One sandbox, for one pull request. The image's entrypoint is the g1t runner,
64 * which does the work and exits; this class only starts it and cleans up
65 * if it dies without reporting.
66 */
67export class AttemptSandbox extends Container<RunnerEnv> {
68 sleepAfter = "45m";
69
70 async run(request: RunRequest): Promise<void> {
71 const { envVars, ...run } = request;
72 await this.ctx.storage.put("run", run);
73 await this.start({ envVars, enableInternet: true });
74 }
75
76 override async onStop({ exitCode }: StopParams): Promise<void> {
77 if (exitCode === 0) return;
78 // The runner closes its own pull request when it fails. This covers a
79 // sandbox that was killed before it could; closing twice is refused
80 // harmlessly.
81 const run = await this.ctx.storage.get<Run>("run");
82 if (run) await workClient(this.env.WORK).closePull(run.actor, run.repo, run.number);
83 }
84}
85
86function buildPrompt(issue: Issue, instructions: string): string {
87 const parts = [
88 "You are a coding agent working in the git repository checked out in the current directory.",
89 `Issue #${issue.number}: ${issue.title}`,
90 issue.body,
91 ];
92 if (issue.checks.length > 0) {
93 parts.push(
94 `These commands must pass when you are done. Run them if the tools are installed:\n${issue.checks.map((check) => `- ${check}`).join("\n")}`,
95 );
96 }
97 if (instructions) parts.push(instructions);
98 parts.push(
99 "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.",
100 );
101 return parts.filter(Boolean).join("\n\n");
102}
103
104export default class RunnerService
105 extends WorkerEntrypoint<RunnerEnv>
106 implements RunnerApi
107{
108 /** A Worker must have an event handler; this service is RPC-only. */
109 fetch(): Response {
110 return new Response("Not found\n", { status: 404 });
111 }
112
113 private configuredModels(): ConfiguredModel[] {
114 return JSON.parse(this.env.AGENT_MODELS);
115 }
116
117 private allowed(viewer: Viewer): boolean {
118 if (!viewer || !this.env.ANTHROPIC_API_KEY) return false;
119 return this.env.HOSTED_AGENT_USERS.split(",")
120 .map((name) => name.trim())
121 .includes(viewer.username);
122 }
123
124 async models(viewer: Viewer): Promise<AgentModel[]> {
125 if (!this.allowed(viewer)) return [];
126 return this.configuredModels().map(({ id, label, description, modelName }) => ({
127 id,
128 label,
129 description,
130 modelName,
131 }));
132 }
133
134 async run(
135 actor: User,
136 repo: RepoPath,
137 issueNumber: number,
138 input: RunHostedInput,
139 ): Promise<Result<Pull[]>> {
140 if (!this.allowed(actor)) {
141 return fail("forbidden", "g1t agents are not enabled for your account.");
142 }
143 const models = this.configuredModels();
144 const model = input.model
145 ? models.find((candidate) => candidate.id === input.model)
146 : models[0];
147 if (!model) return fail("invalid", "That model is not available.");
148 const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), MAX_AGENTS_PER_RUN);
149 const identity = identityClient(this.env.IDENTITY);
150 const work = workClient(this.env.WORK);
151
152 const found = await work.getIssue(repo, issueNumber, actor);
153 if (!found.ok) return found;
154 const { issue } = found.value;
155 const prompt = buildPrompt(issue, input.instructions?.trim() ?? "");
156
157 const pulls: Pull[] = [];
158 for (let i = 0; i < count; i++) {
159 const opened = await work.openPull(actor, repo, {
160 issue: issue.number,
161 agent: AGENT,
162 runtime: "hosted",
163 });
164 // The first failure is the answer; later ones mean some already run.
165 if (!opened.ok) return pulls.length ? ok(pulls) : opened;
166 const pull = opened.value;
167 // Opened without a branch, so it has a fork.
168 const fork = pull.fork!;
169 pulls.push(pull);
170
171 // The sandbox acts as the person who started it, through a token
172 // that only lives as long as a run can.
173 const { token } = await identity.createAccessToken(
174 actor,
175 `g1t agent on ${repo.namespace}/${repo.name}#${pull.number}`,
176 TOKEN_TTL_SECONDS,
177 );
178 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(pull.id));
179 await sandbox.run({
180 actor,
181 repo,
182 number: pull.number,
183 envVars: {
184 G1T_API: "https://api.g1t.sh",
185 G1T_TOKEN: token,
186 G1T_USER: actor.username,
187 G1T_REPO: `${repo.namespace}/${repo.name}`,
188 PULL_NUMBER: String(pull.number),
189 GIT_REMOTE: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
190 COMMIT_MESSAGE: issue.title,
191 PROMPT: prompt,
192 ...modelEnv(this.env, model),
193 },
194 });
195 }
196 return ok(pulls);
197 }
198}