Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

257 lines · 13.3 KBRaw History
1import { DurableObject } from "cloudflare:workers";
2import { Container } from "@cloudflare/containers";
3import type { StopParams } from "@cloudflare/containers";
4import type { Env } from "../env.js";
5import { egressStart, type Egress } from "../lib/egress.js";
6import { endCompute, startCompute } from "../lib/quotas.js";
7import { blockedBy, killSwitches } from "../lib/killswitch.js";
8
9/**
10 * Hosted compute (jobs and sandboxes) under the instance's egress policy (lib/egress.ts), set before
11 * each start. A limited one is kept in storage, so the container starting again from this Durable
12 * Object with its defaults (a sandbox after its idle sleep) is limited too; the allowed hosts are
13 * kept by the library itself.
14 */
15abstract class HostedContainer extends Container<Env> {
16 override enableInternet = true;
17
18 constructor(ctx: DurableObject["ctx"], env: Env) {
19 super(ctx, env);
20 const kept = ctx.storage.kv.get<Egress>("egress");
21 if (kept) this.adopt(kept);
22 }
23
24 /** Applies the policy before a start and returns the variables that tell the agent about it. */
25 protected async useEgress(egress: Egress): Promise<Record<string, string>> {
26 const o = this.adopt(egress);
27 if (o.allowedHosts) await this.setAllowedHosts(o.allowedHosts);
28 if (egress !== "open") this.ctx.storage.kv.put("egress", egress);
29 return o.envVars;
30 }
31
32 private adopt(egress: Egress) {
33 const o = egressStart(egress);
34 this.enableInternet = o.enableInternet;
35 this.interceptHttps = o.interceptHttps;
36 return o;
37 }
38}
39
40/** The job a runner container runs; stopRequested once Diffinery asked it to stop (stopJob). */
41type RunnerJob = { repoId: string; jobId: string; container?: string; stopRequested?: boolean };
42
43/** How long a job's container may take to come up, and how often it is checked meanwhile. */
44const START_WAIT_MS = 180_000;
45const START_POLL_MS = 500;
46
47/**
48 * One container per workflow job. The runner agent inside fetches its job spec
49 * from the Diffinery API, runs the steps and reports back, then exits.
50 */
51export class RunnerContainer extends HostedContainer {
52 // Jobs make no inbound requests, so activity-based sleep must not stop them.
53 override sleepAfter = "6h";
54
55 async startJob(input: { repoId: string; jobId: string; container?: string; token: string; apiUrl: string; egress?: Egress; vcpu?: number }): Promise<void> {
56 await this.ctx.storage.put("job", { repoId: input.repoId, jobId: input.jobId, container: input.container });
57 const egressEnv = await this.useEgress(input.egress ?? "open");
58 await this.start(
59 {
60 envVars: {
61 DIFFINERY_API_URL: input.apiUrl,
62 DIFFINERY_JOB_ID: input.jobId,
63 DIFFINERY_JOB_TOKEN: input.token,
64 // The runner's share of CPU, which the agent measures the job's use against (runner/sizes.ts).
65 ...(input.vcpu ? { DIFFINERY_RUNNER_VCPU: String(input.vcpu) } : {}),
66 ...egressEnv,
67 },
68 },
69 // The library gives up after 8 s and reports "no container instance", but a container on a host
70 // that hasn't pulled the runner image yet takes longer than that to come up: such jobs went back
71 // in line for 30 to 90 s more (measured on 10-leg matrices). Wait for it instead.
72 // The runner listens on no port: the library's own fallback port (33) answers "not listening" once it is up.
73 // Each check can itself take up to 5 s, so the signal bounds the whole wait.
74 { portToCheck: 33, waitInterval: START_POLL_MS, retries: Math.ceil(START_WAIT_MS / START_POLL_MS), signal: AbortSignal.timeout(START_WAIT_MS + 60_000) },
75 );
76 }
77
78 /**
79 * Diffinery ended the job (a timeout, fail-fast, a newer run in its concurrency group): the container
80 * goes at once. `graceful` (someone cancelled the run) sends SIGINT instead: the runner stops the step
81 * under way, runs the steps that run after a cancel and reports. The agent reads SIGTERM as a platform
82 * rollout and keeps the job running for up to 14 minutes, which a cancelled job must not do.
83 */
84 async stopJob(graceful = false): Promise<void> {
85 // Noted first, so onStop can tell Diffinery's own stop (a cancel, a timeout) from the platform's.
86 const job = await this.ctx.storage.get<RunnerJob>("job");
87 if (job) await this.ctx.storage.put("job", { ...job, stopRequested: true });
88 if (!this.ctx.container?.running) return;
89 if (graceful) await this.stop("SIGINT");
90 else await this.destroy();
91 }
92
93 override async onStop(params: StopParams): Promise<void> {
94 const job = await this.ctx.storage.get<RunnerJob>("job");
95 if (!job) return;
96 const repo = this.env.REPO.get(this.env.REPO.idFromName(job.repoId));
97 await repo.containerStopped(job.jobId, params.exitCode ?? null, job.container, !!job.stopRequested);
98 await this.ctx.storage.delete("job");
99 }
100
101 override onError(error: unknown): void {
102 console.error("runner container error", error);
103 }
104}
105
106/**
107 * The smaller hosted runner sizes (runner/sizes.ts): the same runner on smaller Cloudflare instance
108 * types, each its own container class because a container class has one instance type.
109 */
110export class RunnerContainerMedium extends RunnerContainer {}
111export class RunnerContainerSmall extends RunnerContainer {}
112export class RunnerContainerTiny extends RunnerContainer {}
113
114/**
115 * Long-lived task sandbox for AI agents: the repo checked out with toolchains,
116 * and an HTTP API (exec, read/write files) on port 8080.
117 */
118export class SandboxContainer extends HostedContainer {
119 override defaultPort = 8080;
120 override sleepAfter = "30m";
121
122 async boot(envVars: Record<string, string>, secret: string, _name?: string, usage?: { id: string; accountId: string; repoId: string }, egress: Egress = "open"): Promise<void> {
123 await this.ctx.storage.put("secret", secret);
124 // Its running time counts against the repository owner's hosted minutes (lib/quotas.ts).
125 if (usage) await this.ctx.storage.put("usage", usage);
126 envVars = { ...envVars, ...(await this.useEgress(egress)) };
127 // The first start on a host after a deploy pulls the runner image, which takes
128 // well over the library's 8 s / 20 s defaults.
129 await this.startAndWaitForPorts({
130 ports: 8080,
131 startOptions: { envVars, entrypoint: ["node", "/opt/diffinery/agent.mjs", "--sandbox"] },
132 cancellationOptions: { instanceGetTimeoutMS: 180_000, portReadyTimeoutMS: 120_000, waitInterval: 500 },
133 });
134 }
135
136 /** Forward an API call (JSON text) to the sandbox's HTTP server with its secret attached. */
137 async call(path: string, body: string): Promise<{ status: number; body: string; json: boolean }> {
138 const secret = await this.ctx.storage.get<string>("secret");
139 // A sandbox that slept after 30 idle minutes starts again for this call: its time counts again.
140 const usage = await this.ctx.storage.get<{ id: string; accountId: string; repoId: string; part?: number }>("usage");
141 if (usage && !this.ctx.container?.running) {
142 // It doesn't wake while a kill switch stops sandboxes (lib/killswitch.ts).
143 const block = await killSwitches(this.env, usage.repoId).then(
144 (k) => blockedBy(k, "sandbox"),
145 () => null,
146 );
147 if (block) return { status: 403, body: JSON.stringify({ message: block }), json: true };
148 const part = (usage.part ?? 0) + 1;
149 await startCompute(this.env, { id: `${usage.id}:${part}`, accountId: usage.accountId, repoId: usage.repoId, kind: "sandbox" });
150 await this.ctx.storage.put("usage", { ...usage, part });
151 }
152 const res = await this.containerFetch(`http://sandbox${path}`, {
153 method: "POST",
154 headers: { "content-type": "application/json", authorization: `Bearer ${secret}` },
155 body: body || "{}",
156 });
157 return { status: res.status, body: await res.text(), json: (res.headers.get("content-type") ?? "").includes("json") };
158 }
159
160 async shutdown(): Promise<void> {
161 if (this.ctx.container?.running) await this.stop();
162 }
163
164 /** Whether the container is up (calling into a stopped one would start it without its checkout). */
165 async running(): Promise<boolean> {
166 return !!this.ctx.container?.running && !!(await this.ctx.storage.get<string>("secret"));
167 }
168
169 /**
170 * A web terminal's socket (agentic/terminal-web.ts checked who is asking): only a WebSocket
171 * upgrade to /pty, with the sandbox's secret added. A sandbox that isn't running isn't started
172 * again (it would come up without its checkout), and nothing else of the sandbox is reachable here.
173 */
174 override async fetch(request: Request): Promise<Response> {
175 const url = new URL(request.url);
176 if (url.pathname !== "/pty" || request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("not found", { status: 404 });
177 const secret = await this.ctx.storage.get<string>("secret");
178 if (!secret || !this.ctx.container?.running) return new Response("the terminal isn't running", { status: 410 });
179 const headers = new Headers({ upgrade: "websocket", authorization: `Bearer ${secret}` });
180 return this.containerFetch(new Request(`http://sandbox/pty${url.search}`, { headers }), 8080);
181 }
182
183 override async onStop(): Promise<void> {
184 const usage = await this.ctx.storage.get<{ id: string; part?: number }>("usage");
185 if (usage) await endCompute(this.env, usage.part ? `${usage.id}:${usage.part}` : usage.id);
186 }
187}
188
189/**
190 * Local development and end-to-end tests only: `env.dev` binds SANDBOX to this
191 * instead of SandboxContainer, because containers don't run under `wrangler dev`.
192 * The same calls, served by one `agent.mjs --sandbox-pool` process at
193 * SANDBOX_POOL_URL that runs each sandbox's server.
194 */
195export class SandboxPoolDO extends DurableObject<Env> {
196 private poolAuth() {
197 return { authorization: `Bearer ${this.env.SANDBOX_POOL_SECRET ?? ""}` };
198 }
199
200 async boot(envVars: Record<string, string>, secret: string, name?: string): Promise<void> {
201 if (!this.env.SANDBOX_POOL_URL || !name) throw new Error("SANDBOX_POOL_URL is not set: start `agent.mjs --sandbox-pool` for sandboxes under wrangler dev");
202 await this.ctx.storage.put({ secret, name });
203 const r = await fetch(`${this.env.SANDBOX_POOL_URL}/boot`, { method: "POST", headers: this.poolAuth(), body: JSON.stringify({ id: name, secret, env: envVars }) });
204 if (!r.ok) throw new Error(`sandbox pool boot failed: ${r.status} ${await r.text()}`);
205 }
206
207 async call(path: string, body: string): Promise<{ status: number; body: string; json: boolean }> {
208 const [secret, name] = await Promise.all([this.ctx.storage.get<string>("secret"), this.ctx.storage.get<string>("name")]);
209 if (!name) return { status: 404, body: JSON.stringify({ error: "sandbox not found" }), json: true };
210 const res = await fetch(`${this.env.SANDBOX_POOL_URL}/s/${name}${path}`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${secret}` }, body: body || "{}" });
211 return { status: res.status, body: await res.text(), json: (res.headers.get("content-type") ?? "").includes("json") };
212 }
213
214 async shutdown(): Promise<void> {
215 const name = await this.ctx.storage.get<string>("name");
216 if (name) await fetch(`${this.env.SANDBOX_POOL_URL}/shutdown/${name}`, { method: "POST", headers: this.poolAuth() }).catch(() => {});
217 }
218
219 async running(): Promise<boolean> {
220 if (!(await this.ctx.storage.get<string>("name"))) return false;
221 return (await this.call("/pty/status", "{}").catch(() => ({ status: 0 }))).status === 200;
222 }
223
224 /** As SandboxContainer.fetch: a terminal's WebSocket, through the pool. */
225 override async fetch(request: Request): Promise<Response> {
226 const url = new URL(request.url);
227 if (url.pathname !== "/pty" || request.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("not found", { status: 404 });
228 const [secret, name] = await Promise.all([this.ctx.storage.get<string>("secret"), this.ctx.storage.get<string>("name")]);
229 if (!secret || !name) return new Response("the terminal isn't running", { status: 410 });
230 const res = await fetch(`${this.env.SANDBOX_POOL_URL}/s/${name}/pty${url.search}`, { headers: { upgrade: "websocket", authorization: `Bearer ${secret}` } });
231 if (!res.webSocket) return new Response(await res.text(), { status: res.status === 101 ? 502 : res.status });
232 return pipeSocket(res.webSocket);
233 }
234}
235
236/** Hands a WebSocket this Durable Object holds to the caller, relaying frames and closes both ways. */
237function pipeSocket(upstream: WebSocket): Response {
238 const [client, server] = Object.values(new WebSocketPair()) as [WebSocket, WebSocket];
239 upstream.accept();
240 server.accept();
241 const code = (c: number) => (c === 1005 || c === 1006 ? 1000 : c);
242 // Binary frames can arrive as Blobs; reading one is async, so each direction keeps its order with a chain.
243 const relay = (from: WebSocket, to: WebSocket) => {
244 let chain = Promise.resolve();
245 from.addEventListener("message", (e) => {
246 const data = e.data as string | ArrayBuffer | Blob;
247 chain = chain.then(async () => to.send(data instanceof Blob ? await data.arrayBuffer() : data)).catch(() => to.close(1011, "relay failed"));
248 });
249 };
250 relay(server, upstream);
251 relay(upstream, server);
252 server.addEventListener("close", (e) => upstream.close(code(e.code), e.reason));
253 upstream.addEventListener("close", (e) => server.close(code(e.code), e.reason));
254 server.addEventListener("error", () => upstream.close(1011, "client error"));
255 upstream.addEventListener("error", () => server.close(1011, "sandbox error"));
256 return new Response(null, { status: 101, webSocket: client });
257}