Hosted agents: sandboxes on Cloudflare Containers started from an intent
- services/runner: starts one container per attempt running the g1t runner, limited to an allowlist of accounts; abandons the attempt if a sandbox dies without reporting - site: Run hosted agents panel on intents with live refresh; attempt summaries and agent messages render as markdown; GitHub-style clone box with HTTPS, SSH and Agent tabs
14 files+443−180/14 viewed
| 1 | + | import { Link } from "react-router"; | |
| 2 | + | ||
| 3 | + | import { CopyLine } from "./ui"; | |
| 4 | + | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; | |
| 5 | + | ||
| 6 | + | /** The ways to get a repository onto a machine or in front of an agent. */ | |
| 7 | + | export function CloneBox({ path }: { path: string }) { | |
| 8 | + | return ( | |
| 9 | + | <Tabs defaultValue="https"> | |
| 10 | + | <TabsList> | |
| 11 | + | <TabsTrigger value="https">HTTPS</TabsTrigger> | |
| 12 | + | <TabsTrigger value="ssh">SSH</TabsTrigger> | |
| 13 | + | <TabsTrigger value="agent">Agent</TabsTrigger> | |
| 14 | + | </TabsList> | |
| 15 | + | <TabsContent value="https"> | |
| 16 | + | <CopyLine text={`https://g1t.sh/${path}.git`} /> | |
| 17 | + | <p className="mt-2 text-xs text-muted"> | |
| 18 | + | To push, use your username and an{" "} | |
| 19 | + | <Link to="/settings" className="text-fg underline underline-offset-4"> | |
| 20 | + | access token | |
| 21 | + | </Link>{" "} | |
| 22 | + | as the password. | |
| 23 | + | </p> | |
| 24 | + | </TabsContent> | |
| 25 | + | <TabsContent value="ssh"> | |
| 26 | + | <p className="rounded-lg border border-dashed border-line p-3 text-xs text-muted"> | |
| 27 | + | Git over SSH is not available yet. Use HTTPS for now. | |
| 28 | + | </p> | |
| 29 | + | </TabsContent> | |
| 30 | + | <TabsContent value="agent"> | |
| 31 | + | <CopyLine | |
| 32 | + | prompt | |
| 33 | + | text='claude mcp add --transport http g1t https://mcp.g1t.sh --header "Authorization: Bearer $G1T_TOKEN"' | |
| 34 | + | /> | |
| 35 | + | <p className="mt-2 text-xs text-muted"> | |
| 36 | + | Connects Claude Code to g1t. Then ask it to work on an intent in{" "} | |
| 37 | + | <span className="font-mono text-fg">{path}</span>.{" "} | |
| 38 | + | <Link to="/docs/agents" className="text-fg underline underline-offset-4"> | |
| 39 | + | More | |
| 40 | + | </Link> | |
| 41 | + | </p> | |
| 42 | + | </TabsContent> | |
| 43 | + | </Tabs> | |
| 44 | + | ); | |
| 45 | + | } |
| 3 | 3 | ||
| 4 | 4 | import type { BlobView as Blob, Commit, TreeView as Tree } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | + | import { CloneBox } from "./clone-box"; | |
| 6 | 7 | import { Markdown } from "./markdown"; | |
| 7 | 8 | import { Avatar, CopyLine, TimeAgo } from "./ui"; | |
| 8 | 9 | ||
| 142 | 143 | <section> | |
| 143 | 144 | <h2 className="text-sm font-medium">Clone</h2> | |
| 144 | 145 | <div className="mt-2"> | |
| 145 | − | <CopyLine text={`git clone ${cloneUrl}`} /> | |
| 146 | + | <CloneBox path={`${repo.namespace}/${repo.name}`} /> | |
| 146 | 147 | </div> | |
| 147 | 148 | </section> | |
| 148 | 149 | <section> |
| 1 | + | import { Tabs as Primitive } from "radix-ui"; | |
| 2 | + | import type { ComponentProps } from "react"; | |
| 3 | + | ||
| 4 | + | import { cn } from "../../lib/cn"; | |
| 5 | + | ||
| 6 | + | // shadcn/ui's tabs, styled with g1t's tokens. | |
| 7 | + | ||
| 8 | + | export const Tabs = Primitive.Root; | |
| 9 | + | ||
| 10 | + | export function TabsList({ | |
| 11 | + | className, | |
| 12 | + | ...props | |
| 13 | + | }: ComponentProps<typeof Primitive.List>) { | |
| 14 | + | return ( | |
| 15 | + | <Primitive.List | |
| 16 | + | className={cn( | |
| 17 | + | "inline-flex items-center gap-1 rounded-lg border border-line bg-bg p-1", | |
| 18 | + | className, | |
| 19 | + | )} | |
| 20 | + | {...props} | |
| 21 | + | /> | |
| 22 | + | ); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | export function TabsTrigger({ | |
| 26 | + | className, | |
| 27 | + | ...props | |
| 28 | + | }: ComponentProps<typeof Primitive.Trigger>) { | |
| 29 | + | return ( | |
| 30 | + | <Primitive.Trigger | |
| 31 | + | className={cn( | |
| 32 | + | "rounded-md px-2.5 py-1 text-xs font-medium text-muted transition-colors outline-none hover:text-fg", | |
| 33 | + | "data-[state=active]:bg-raised data-[state=active]:text-fg", | |
| 34 | + | "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-muted", | |
| 35 | + | className, | |
| 36 | + | )} | |
| 37 | + | {...props} | |
| 38 | + | /> | |
| 39 | + | ); | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | export function TabsContent({ | |
| 43 | + | className, | |
| 44 | + | ...props | |
| 45 | + | }: ComponentProps<typeof Primitive.Content>) { | |
| 46 | + | return ( | |
| 47 | + | <Primitive.Content className={cn("mt-3 outline-none", className)} {...props} /> | |
| 48 | + | ); | |
| 49 | + | } |
| 14 | 14 | import type { SessionEntry } from "@g1t/contracts"; | |
| 15 | 15 | ||
| 16 | 16 | import type { Route } from "./+types/attempt"; | |
| 17 | + | import { Markdown } from "../../components/markdown"; | |
| 17 | 18 | import { | |
| 18 | 19 | Button, | |
| 19 | 20 | CopyLine, | |
| 129 | 130 | ? "Note" | |
| 130 | 131 | : agent} | |
| 131 | 132 | </p> | |
| 132 | − | <p | |
| 133 | − | className={`mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap ${ | |
| 134 | − | entry.kind === "prompt" ? "font-medium" : "" | |
| 135 | − | }`} | |
| 136 | − | > | |
| 137 | − | {entry.text} | |
| 138 | − | </p> | |
| 133 | + | {entry.kind === "message" ? ( | |
| 134 | + | <div className="mt-1"> | |
| 135 | + | <Markdown source={entry.text} /> | |
| 136 | + | </div> | |
| 137 | + | ) : ( | |
| 138 | + | <p | |
| 139 | + | className={`mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap ${ | |
| 140 | + | entry.kind === "prompt" ? "font-medium" : "" | |
| 141 | + | }`} | |
| 142 | + | > | |
| 143 | + | {entry.text} | |
| 144 | + | </p> | |
| 145 | + | )} | |
| 139 | 146 | </div> | |
| 140 | 147 | )} | |
| 141 | 148 | {entry.commit && ( | |
| 202 | 209 | <h3 className="text-xs font-medium tracking-wide text-faint uppercase"> | |
| 203 | 210 | Summary | |
| 204 | 211 | </h3> | |
| 205 | − | <p className="mt-2 leading-relaxed whitespace-pre-wrap"> | |
| 206 | − | {attempt.summary} | |
| 207 | − | </p> | |
| 212 | + | <div className="mt-2"> | |
| 213 | + | <Markdown source={attempt.summary} /> | |
| 214 | + | </div> | |
| 208 | 215 | </section> | |
| 209 | 216 | )} | |
| 210 | 217 |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | − | import { Bot, GitCommitHorizontal, Terminal } from "lucide-react"; | |
| 3 | − | import { Form, Link, redirect } from "react-router"; | |
| 2 | + | import { Bot, GitCommitHorizontal, Play, Sparkles, Terminal } from "lucide-react"; | |
| 3 | + | import { useEffect } from "react"; | |
| 4 | + | import { | |
| 5 | + | Form, | |
| 6 | + | Link, | |
| 7 | + | redirect, | |
| 8 | + | useNavigation, | |
| 9 | + | useRevalidator, | |
| 10 | + | } from "react-router"; | |
| 4 | 11 | ||
| 5 | 12 | import type { Route } from "./+types/intent"; | |
| 6 | 13 | import { Markdown } from "../../components/markdown"; | |
| 12 | 19 | ErrorText, | |
| 13 | 20 | Input, | |
| 14 | 21 | Status, | |
| 22 | + | Textarea, | |
| 15 | 23 | TimeAgo, | |
| 16 | 24 | } from "../../components/ui"; | |
| 17 | 25 | import { | |
| 21 | 29 | unwrap, | |
| 22 | 30 | } from "../../lib/session.server"; | |
| 23 | 31 | ||
| 32 | + | const REFRESH_MS = 4000; | |
| 33 | + | ||
| 24 | 34 | export function meta({ loaderData, params }: Route.MetaArgs) { | |
| 25 | 35 | const title = loaderData ? `${loaderData.intent.title} · ` : ""; | |
| 26 | 36 | return [{ title: `${title}${params.owner}/${params.repo} · g1t` }]; | |
| 35 | 45 | viewer, | |
| 36 | 46 | ), | |
| 37 | 47 | ); | |
| 38 | − | return { ...detail, viewer }; | |
| 48 | + | return { | |
| 49 | + | ...detail, | |
| 50 | + | viewer, | |
| 51 | + | canRunHosted: await env.RUNNER.available(viewer), | |
| 52 | + | }; | |
| 39 | 53 | } | |
| 40 | 54 | ||
| 41 | 55 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 44 | 58 | const form = await request.formData(); | |
| 45 | 59 | const intentId = String(form.get("intentId") ?? ""); | |
| 46 | 60 | ||
| 61 | + | if (form.get("action") === "run-hosted") { | |
| 62 | + | const result = await env.RUNNER.run(user, intentId, { | |
| 63 | + | count: Number(form.get("count")), | |
| 64 | + | instructions: String(form.get("instructions") ?? ""), | |
| 65 | + | }); | |
| 66 | + | return result.ok ? null : { error: result.error.message }; | |
| 67 | + | } | |
| 47 | 68 | if (form.get("action") === "withdraw") { | |
| 48 | 69 | const result = await env.WORK.withdrawIntent(user, intentId); | |
| 49 | 70 | return result.ok ? null : { error: result.error.message }; | |
| 63 | 84 | actionData, | |
| 64 | 85 | params, | |
| 65 | 86 | }: Route.ComponentProps) { | |
| 66 | − | const { intent, attempts, viewer } = loaderData; | |
| 87 | + | const { intent, attempts, viewer, canRunHosted } = loaderData; | |
| 88 | + | ||
| 89 | + | // Follow running attempts without a manual reload. | |
| 90 | + | const revalidator = useRevalidator(); | |
| 91 | + | const navigation = useNavigation(); | |
| 92 | + | const running = attempts.some((attempt) => attempt.status === "working"); | |
| 93 | + | useEffect(() => { | |
| 94 | + | if (!running) return; | |
| 95 | + | const timer = setInterval(() => { | |
| 96 | + | if (document.visibilityState === "visible") revalidator.revalidate(); | |
| 97 | + | }, REFRESH_MS); | |
| 98 | + | return () => clearInterval(timer); | |
| 99 | + | }, [running, revalidator]); | |
| 100 | + | const starting = navigation.formData?.get("action") === "run-hosted"; | |
| 67 | 101 | const base = `/${params.owner}/${params.repo}`; | |
| 68 | 102 | const open = intent.status === "open"; | |
| 69 | 103 | return ( | |
| 129 | 163 | <Bot size={15} className="text-faint" /> | |
| 130 | 164 | {attempt.agent} | |
| 131 | 165 | </span> | |
| 166 | + | {attempt.runtime === "hosted" && ( | |
| 167 | + | <span className="rounded-full border border-line px-1.5 py-px text-[0.6875rem] text-faint"> | |
| 168 | + | hosted | |
| 169 | + | </span> | |
| 170 | + | )} | |
| 132 | 171 | <span className="text-sm text-faint"> | |
| 133 | 172 | attempt {attempt.number} | |
| 134 | 173 | </span> | |
| 172 | 211 | </section> | |
| 173 | 212 | )} | |
| 174 | 213 | ||
| 214 | + | {open && canRunHosted && ( | |
| 215 | + | <section className="rounded-xl border border-accent/30 bg-accent/5 p-4"> | |
| 216 | + | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 217 | + | <Sparkles size={15} className="text-accent" /> | |
| 218 | + | Run hosted agents | |
| 219 | + | </h3> | |
| 220 | + | <p className="mt-1 text-xs text-muted"> | |
| 221 | + | g1t starts Claude Code in a sandbox for each attempt. They work | |
| 222 | + | in parallel, each in its own fork. | |
| 223 | + | </p> | |
| 224 | + | <Form method="post" className="mt-3 space-y-2"> | |
| 225 | + | <input type="hidden" name="intentId" value={intent.id} /> | |
| 226 | + | <input type="hidden" name="action" value="run-hosted" /> | |
| 227 | + | <label className="flex items-center justify-between gap-3 text-sm"> | |
| 228 | + | <span className="text-muted">Agents</span> | |
| 229 | + | <select | |
| 230 | + | name="count" | |
| 231 | + | defaultValue="1" | |
| 232 | + | className="rounded-md border border-line bg-bg px-2 py-1 text-sm" | |
| 233 | + | > | |
| 234 | + | {[1, 2, 3, 4, 5].map((count) => ( | |
| 235 | + | <option key={count} value={count}> | |
| 236 | + | {count} | |
| 237 | + | </option> | |
| 238 | + | ))} | |
| 239 | + | </select> | |
| 240 | + | </label> | |
| 241 | + | <Textarea | |
| 242 | + | name="instructions" | |
| 243 | + | rows={2} | |
| 244 | + | placeholder="Extra guidance (optional)" | |
| 245 | + | /> | |
| 246 | + | <div className="*:w-full"> | |
| 247 | + | <Button variant="accent" type="submit" disabled={starting}> | |
| 248 | + | <Play size={14} /> | |
| 249 | + | {starting ? "Starting sandboxes…" : "Run"} | |
| 250 | + | </Button> | |
| 251 | + | </div> | |
| 252 | + | </Form> | |
| 253 | + | </section> | |
| 254 | + | )} | |
| 255 | + | ||
| 175 | 256 | {open && ( | |
| 176 | 257 | <section className="rounded-xl border border-line bg-surface p-4"> | |
| 177 | − | <h3 className="text-sm font-medium">Put an agent on it</h3> | |
| 258 | + | <h3 className="text-sm font-medium">Bring your own agent</h3> | |
| 178 | 259 | <p className="mt-1 text-xs text-muted"> | |
| 179 | 260 | With g1t connected to your agent, give it this intent id. | |
| 180 | 261 | </p> |
| 1 | − | import type { EventsApi, ServiceBinding, WorkApi } from "@g1t/contracts"; | |
| 1 | + | import type { EventsApi, RunnerApi, ServiceBinding, WorkApi } from "@g1t/contracts"; | |
| 2 | 2 | ||
| 3 | 3 | declare global { | |
| 4 | 4 | namespace Cloudflare { | |
| 8 | 8 | REPOS: ServiceBinding & { fetch(request: Request): Promise<Response> }; | |
| 9 | 9 | WORK: WorkApi; | |
| 10 | 10 | EVENTS: EventsApi; | |
| 11 | + | RUNNER: RunnerApi; | |
| 11 | 12 | } | |
| 12 | 13 | } | |
| 13 | 14 | interface Env extends Cloudflare.Env {} |
| 10 | 10 | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 11 | 11 | { "binding": "REPOS", "service": "g1t-repos" }, | |
| 12 | 12 | { "binding": "WORK", "service": "g1t-work" }, | |
| 13 | − | { "binding": "EVENTS", "service": "g1t-events" } | |
| 13 | + | { "binding": "EVENTS", "service": "g1t-events" }, | |
| 14 | + | { "binding": "RUNNER", "service": "g1t-runner" } | |
| 14 | 15 | ], | |
| 15 | 16 | "observability": { "enabled": true }, | |
| 16 | 17 | "upload_source_maps": true |
| 327 | 327 | "node": ">=6.9.0" | |
| 328 | 328 | } | |
| 329 | 329 | }, | |
| 330 | + | "node_modules/@cloudflare/containers": { | |
| 331 | + | "version": "0.3.7", | |
| 332 | + | "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.3.7.tgz", | |
| 333 | + | "integrity": "sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw==", | |
| 334 | + | "license": "MIT OR Apache-2.0" | |
| 335 | + | }, | |
| 330 | 336 | "node_modules/@cloudflare/kv-asset-handler": { | |
| 331 | 337 | "version": "0.5.0", | |
| 332 | 338 | "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", | |
| 976 | 982 | "resolved": "services/events", | |
| 977 | 983 | "link": true | |
| 978 | 984 | }, | |
| 985 | + | "node_modules/@g1t/runner": { | |
| 986 | + | "resolved": "services/runner", | |
| 987 | + | "link": true | |
| 988 | + | }, | |
| 979 | 989 | "node_modules/@g1t/web": { | |
| 980 | 990 | "resolved": "apps/web", | |
| 981 | 991 | "link": true | |
| 7486 | 7496 | "@g1t/contracts": "*" | |
| 7487 | 7497 | } | |
| 7488 | 7498 | }, | |
| 7499 | + | "services/runner": { | |
| 7500 | + | "name": "@g1t/runner", | |
| 7501 | + | "version": "0.1.0", | |
| 7502 | + | "license": "MIT", | |
| 7503 | + | "dependencies": { | |
| 7504 | + | "@cloudflare/containers": "^0.3.7", | |
| 7505 | + | "@g1t/contracts": "*" | |
| 7506 | + | } | |
| 7507 | + | }, | |
| 7489 | 7508 | "services/work": { | |
| 7490 | 7509 | "name": "@g1t/work", | |
| 7491 | 7510 | "version": "0.1.0", |
| 5 | 5 | export * from "./names"; | |
| 6 | 6 | export * from "./repos"; | |
| 7 | 7 | export * from "./result"; | |
| 8 | + | export * from "./runner"; | |
| 8 | 9 | export * from "./work"; |
| 1 | + | import type { User, Viewer } from "./identity"; | |
| 2 | + | import type { Result } from "./result"; | |
| 3 | + | import type { Attempt } from "./work"; | |
| 4 | + | ||
| 5 | + | export type RunHostedInput = { | |
| 6 | + | /** How many agents to race on the intent, each in its own sandbox. */ | |
| 7 | + | count: number; | |
| 8 | + | /** Extra guidance appended to the intent's brief for these runs. */ | |
| 9 | + | instructions?: string; | |
| 10 | + | }; | |
| 11 | + | ||
| 12 | + | /** Hosted agents: sandboxes on g1t that work on an intent. */ | |
| 13 | + | export interface RunnerApi { | |
| 14 | + | /** Whether this viewer may start hosted agents. */ | |
| 15 | + | available(viewer: Viewer): Promise<boolean>; | |
| 16 | + | /** | |
| 17 | + | * Starts `count` attempts on the intent, each run by an agent in its own | |
| 18 | + | * sandbox. Returns as soon as the sandboxes are starting; progress shows | |
| 19 | + | * up in each attempt's session. | |
| 20 | + | */ | |
| 21 | + | run(actor: User, intentId: string, input: RunHostedInput): Promise<Result<Attempt[]>>; | |
| 22 | + | } |
| 1 | + | { | |
| 2 | + | "name": "@g1t/runner", | |
| 3 | + | "version": "0.1.0", | |
| 4 | + | "private": true, | |
| 5 | + | "type": "module", | |
| 6 | + | "license": "MIT", | |
| 7 | + | "scripts": { | |
| 8 | + | "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json", | |
| 9 | + | "deploy": "wrangler deploy" | |
| 10 | + | }, | |
| 11 | + | "dependencies": { | |
| 12 | + | "@cloudflare/containers": "^0.3.7", | |
| 13 | + | "@g1t/contracts": "*" | |
| 14 | + | } | |
| 15 | + | } |
| 1 | + | import { Container, type StopParams } from "@cloudflare/containers"; | |
| 2 | + | import { WorkerEntrypoint } from "cloudflare:workers"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | type Attempt, | |
| 6 | + | type Intent, | |
| 7 | + | type Result, | |
| 8 | + | type RunHostedInput, | |
| 9 | + | type RunnerApi, | |
| 10 | + | type ServiceBinding, | |
| 11 | + | type User, | |
| 12 | + | type Viewer, | |
| 13 | + | type WorkApi, | |
| 14 | + | fail, | |
| 15 | + | identityClient, | |
| 16 | + | ok, | |
| 17 | + | } from "@g1t/contracts"; | |
| 18 | + | ||
| 19 | + | export interface RunnerEnv { | |
| 20 | + | SANDBOX: DurableObjectNamespace<AttemptSandbox>; | |
| 21 | + | IDENTITY: ServiceBinding; | |
| 22 | + | WORK: WorkApi; | |
| 23 | + | /** Secret. The model key the hosted agent runs on. */ | |
| 24 | + | ANTHROPIC_API_KEY?: string; | |
| 25 | + | /** | |
| 26 | + | * Comma-separated usernames allowed to start hosted agents. Runs spend the | |
| 27 | + | * key above, so this stays an allowlist until accounts bring their own. | |
| 28 | + | */ | |
| 29 | + | HOSTED_AGENT_USERS: string; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | const MAX_AGENTS_PER_RUN = 5; | |
| 33 | + | /** A run that takes longer than this has its token expire under it. */ | |
| 34 | + | const TOKEN_TTL_SECONDS = 2 * 60 * 60; | |
| 35 | + | const AGENT = "claude-code"; | |
| 36 | + | ||
| 37 | + | type RunRequest = { actor: User; attemptId: string; envVars: Record<string, string> }; | |
| 38 | + | ||
| 39 | + | /** | |
| 40 | + | * One sandbox, for one attempt. The image's entrypoint is the g1t runner, | |
| 41 | + | * which does the work and exits; this class only starts it and cleans up | |
| 42 | + | * if it dies without reporting. | |
| 43 | + | */ | |
| 44 | + | export class AttemptSandbox extends Container<RunnerEnv> { | |
| 45 | + | sleepAfter = "45m"; | |
| 46 | + | ||
| 47 | + | async run(request: RunRequest): Promise<void> { | |
| 48 | + | await this.ctx.storage.put("run", { | |
| 49 | + | actor: request.actor, | |
| 50 | + | attemptId: request.attemptId, | |
| 51 | + | }); | |
| 52 | + | await this.start({ envVars: request.envVars, enableInternet: true }); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | override async onStop({ exitCode }: StopParams): Promise<void> { | |
| 56 | + | if (exitCode === 0) return; | |
| 57 | + | // The runner abandons its own attempt when it fails. This covers a | |
| 58 | + | // sandbox that was killed before it could; abandoning twice is refused | |
| 59 | + | // harmlessly. | |
| 60 | + | const run = await this.ctx.storage.get<Pick<RunRequest, "actor" | "attemptId">>("run"); | |
| 61 | + | if (run) await this.env.WORK.abandonAttempt(run.actor, run.attemptId); | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | function buildPrompt(intent: Intent, instructions: string): string { | |
| 66 | + | const parts = [ | |
| 67 | + | "You are a coding agent working in the git repository checked out in the current directory.", | |
| 68 | + | `Goal: ${intent.title}`, | |
| 69 | + | intent.brief, | |
| 70 | + | ]; | |
| 71 | + | if (intent.checks.length > 0) { | |
| 72 | + | parts.push( | |
| 73 | + | `These commands must pass when you are done. Run them if the tools are installed:\n${intent.checks.map((check) => `- ${check}`).join("\n")}`, | |
| 74 | + | ); | |
| 75 | + | } | |
| 76 | + | if (instructions) parts.push(instructions); | |
| 77 | + | parts.push( | |
| 78 | + | "Make the change and keep it focused on the goal. 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.", | |
| 79 | + | ); | |
| 80 | + | return parts.filter(Boolean).join("\n\n"); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | export default class RunnerService | |
| 84 | + | extends WorkerEntrypoint<RunnerEnv> | |
| 85 | + | implements RunnerApi | |
| 86 | + | { | |
| 87 | + | /** A Worker must have an event handler; this service is RPC-only. */ | |
| 88 | + | fetch(): Response { | |
| 89 | + | return new Response("Not found\n", { status: 404 }); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | async available(viewer: Viewer): Promise<boolean> { | |
| 93 | + | if (!viewer || !this.env.ANTHROPIC_API_KEY) return false; | |
| 94 | + | return this.env.HOSTED_AGENT_USERS.split(",") | |
| 95 | + | .map((name) => name.trim()) | |
| 96 | + | .includes(viewer.username); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | async run( | |
| 100 | + | actor: User, | |
| 101 | + | intentId: string, | |
| 102 | + | input: RunHostedInput, | |
| 103 | + | ): Promise<Result<Attempt[]>> { | |
| 104 | + | if (!(await this.available(actor))) { | |
| 105 | + | return fail("forbidden", "Hosted agents are not enabled for your account."); | |
| 106 | + | } | |
| 107 | + | const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), MAX_AGENTS_PER_RUN); | |
| 108 | + | const identity = identityClient(this.env.IDENTITY); | |
| 109 | + | ||
| 110 | + | const attempts: Attempt[] = []; | |
| 111 | + | for (let i = 0; i < count; i++) { | |
| 112 | + | const started = await this.env.WORK.startAttempt(actor, intentId, { | |
| 113 | + | agent: AGENT, | |
| 114 | + | runtime: "hosted", | |
| 115 | + | }); | |
| 116 | + | // The first failure is the answer; later ones mean some already run. | |
| 117 | + | if (!started.ok) return attempts.length ? ok(attempts) : started; | |
| 118 | + | const attempt = started.value; | |
| 119 | + | attempts.push(attempt); | |
| 120 | + | ||
| 121 | + | const found = await this.env.WORK.getAttempt(attempt.id, actor); | |
| 122 | + | if (!found.ok) return found; | |
| 123 | + | // The sandbox acts as the person who started it, through a token | |
| 124 | + | // that only lives as long as a run can. | |
| 125 | + | const { token } = await identity.createAccessToken( | |
| 126 | + | actor, | |
| 127 | + | `Hosted attempt ${attempt.id}`, | |
| 128 | + | TOKEN_TTL_SECONDS, | |
| 129 | + | ); | |
| 130 | + | const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(attempt.id)); | |
| 131 | + | await sandbox.run({ | |
| 132 | + | actor, | |
| 133 | + | attemptId: attempt.id, | |
| 134 | + | envVars: { | |
| 135 | + | G1T_API: "https://api.g1t.sh", | |
| 136 | + | G1T_TOKEN: token, | |
| 137 | + | G1T_USER: actor.username, | |
| 138 | + | ATTEMPT_ID: attempt.id, | |
| 139 | + | GIT_REMOTE: `https://g1t.sh/${attempt.fork.namespace}/${attempt.fork.name}.git`, | |
| 140 | + | COMMIT_MESSAGE: found.value.intent.title, | |
| 141 | + | PROMPT: buildPrompt(found.value.intent, input.instructions?.trim() ?? ""), | |
| 142 | + | ANTHROPIC_API_KEY: this.env.ANTHROPIC_API_KEY!, | |
| 143 | + | }, | |
| 144 | + | }); | |
| 145 | + | } | |
| 146 | + | return ok(attempts); | |
| 147 | + | } | |
| 148 | + | } |
| 1 | + | { | |
| 2 | + | "extends": "../../tsconfig.base.json", | |
| 3 | + | "include": ["src/**/*", "worker-configuration.d.ts"] | |
| 4 | + | } |
| 1 | + | { | |
| 2 | + | "$schema": "../../node_modules/wrangler/config-schema.json", | |
| 3 | + | "name": "g1t-runner", | |
| 4 | + | "account_id": "1e6f2cffa3f445920836e8ebe446bb58", | |
| 5 | + | "compatibility_date": "2026-09-26", | |
| 6 | + | "main": "./src/index.ts", | |
| 7 | + | "workers_dev": false, | |
| 8 | + | "containers": [ | |
| 9 | + | { | |
| 10 | + | "class_name": "AttemptSandbox", | |
| 11 | + | // Built from the repository root so the image can compile the | |
| 12 | + | // runner crate. | |
| 13 | + | "image": "./Dockerfile", | |
| 14 | + | "image_build_context": "../..", | |
| 15 | + | "instance_type": "standard-1", | |
| 16 | + | "max_instances": 10 | |
| 17 | + | } | |
| 18 | + | ], | |
| 19 | + | "durable_objects": { | |
| 20 | + | "bindings": [{ "name": "SANDBOX", "class_name": "AttemptSandbox" }] | |
| 21 | + | }, | |
| 22 | + | "migrations": [{ "tag": "v1", "new_sqlite_classes": ["AttemptSandbox"] }], | |
| 23 | + | "services": [ | |
| 24 | + | { "binding": "IDENTITY", "service": "g1t-identity" }, | |
| 25 | + | { "binding": "WORK", "service": "g1t-work" } | |
| 26 | + | ], | |
| 27 | + | "vars": { | |
| 28 | + | "HOSTED_AGENT_USERS": "syntaqx" | |
| 29 | + | }, | |
| 30 | + | "observability": { "enabled": true } | |
| 31 | + | } |