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.

298 lines · 17.7 KBRaw History
1import type { AppContext } from "../auth/principal.js";
2import { blockedBy, killSwitches } from "../lib/killswitch.js";
3import type { Env } from "../env.js";
4import type { loadRepo } from "../http/common.js";
5import type { Account, Directory, Repo } from "../lib/directory.js";
6import { mayStart } from "../lib/quotas.js";
7import { HttpError, newId, now } from "../lib/util.js";
8import { taskCaller, TaskAccessError } from "./access.js";
9import type { TaskView } from "./ProjectDO.js";
10import { MAX_GOAL, RUNTIME_VERSIONS, type RuntimeId } from "./runtimes.js";
11import { projectStub, requirePersonSession, starter } from "./tasks.js";
12import { terminalRefusal } from "./org-policy.js";
13
14/**
15 * Web terminals (phase 4, /mnt/project-files/agentic/phase4-design.md): a sandbox one person opens
16 * to work in directly, for example signed in to their own Claude or ChatGPT subscription.
17 *
18 * - Only a person in a browser session opens one, with write access, driving one of their agents
19 * (the sandbox's git credential acts as that agent, capped at write, as for tasks).
20 * - Only that person sees it or attaches to it: not an admin, not the repository's owner, because
21 * the shell may hold their provider sign-in. The sandbox API never reaches it either.
22 * - It ends on End, after TERMINAL_IDLE_MS with nobody attached, or after TERMINAL_MAX_MS; ending
23 * stops the container (its disk, and any sign-in, go with it) and revokes its git credential.
24 * - Nothing typed or shown is stored outside the container.
25 */
26
27type Loaded = Awaited<ReturnType<typeof loadRepo>>;
28
29export const MAX_TERMINALS_PER_REPO = 2;
30export const MAX_TERMINALS = 5;
31export const TERMINAL_IDLE_MS = 30 * 60_000;
32export const TERMINAL_MAX_MS = 8 * 3600_000;
33/** Bump when the notice changes: everyone opts in again. */
34export const TERMINAL_TERMS_VERSION = 3;
35
36export type TerminalStatus = "starting" | "running" | "ended";
37export type TerminalEndReason = "person" | "idle" | "limit" | "access" | "stopped" | "failed" | "disabled" | "suspended";
38
39export interface TerminalRow {
40 id: string;
41 repo_id: string;
42 person_id: string;
43 agent_id: string;
44 ref: string;
45 status: TerminalStatus;
46 sandbox_id: string | null;
47 created_at: string;
48 started_at: string | null;
49 ended_at: string | null;
50 end_reason: string | null;
51 /** 1 when the person opened it to run agent tasks (phase 4b); a terminal opened without it never runs one. */
52 tasks: number;
53}
54
55export const TERMINAL_END_TEXT: Record<TerminalEndReason, string> = {
56 person: "You ended it.",
57 idle: `Nobody was attached for ${TERMINAL_IDLE_MS / 60_000} minutes.`,
58 limit: `It reached the ${TERMINAL_MAX_MS / 3600_000}-hour limit.`,
59 access: "You or the agent no longer have write access here.",
60 stopped: "Its sandbox stopped.",
61 failed: "Its sandbox didn't start.",
62 disabled: "Terminals were switched off on this Diffinery.",
63 suspended: "A site administrator suspended Actions and sandboxes here, or paused hosted compute on this Diffinery.",
64};
65
66/** Terminals can be switched off for the whole instance (`TERMINALS=off`). */
67export function terminalsEnabled(env: Env): boolean {
68 return (env.TERMINALS ?? "on").toLowerCase() !== "off";
69}
70
71export function terminalId(s: string | undefined): string {
72 if (!s || !/^[A-Za-z0-9_-]{1,64}$/.test(s)) throw new HttpError(404, "terminal not found");
73 return s;
74}
75
76export class Terminals {
77 constructor(private env: Env) {}
78
79 private get db() {
80 return this.env.DB;
81 }
82
83 async get(id: string): Promise<TerminalRow | null> {
84 return (await this.db.prepare("SELECT * FROM agent_terminals WHERE id = ?").bind(id).first<TerminalRow>()) ?? null;
85 }
86
87 /** A terminal as its opener sees it; anyone else, admins included, gets 404. */
88 async mine(id: string, person: Account, repo: Repo): Promise<TerminalRow> {
89 const t = await this.get(id);
90 if (!t || t.person_id !== person.id || t.repo_id !== repo.id) throw new HttpError(404, "terminal not found");
91 return t;
92 }
93
94 async list(personId: string, repoId: string, limit = 20): Promise<TerminalRow[]> {
95 return (await this.db.prepare("SELECT * FROM agent_terminals WHERE person_id = ? AND repo_id = ? ORDER BY created_at DESC LIMIT ?").bind(personId, repoId, limit).all<TerminalRow>()).results;
96 }
97
98 async open(personId: string): Promise<{ total: number; byRepo: Map<string, number> }> {
99 const rows = (await this.db.prepare("SELECT repo_id, count(*) AS n FROM agent_terminals WHERE person_id = ? AND status != 'ended' GROUP BY repo_id").bind(personId).all<{ repo_id: string; n: number }>()).results;
100 return { total: rows.reduce((a, r) => a + r.n, 0), byRepo: new Map(rows.map((r) => [r.repo_id, r.n])) };
101 }
102
103 async termsAt(personId: string): Promise<string | null> {
104 const r = await this.db.prepare("SELECT version, opted_in_at FROM terminal_terms WHERE account_id = ?").bind(personId).first<{ version: number; opted_in_at: string }>();
105 return r && r.version >= TERMINAL_TERMS_VERSION ? r.opted_in_at : null;
106 }
107
108 async acceptTerms(personId: string): Promise<string> {
109 const at = now();
110 await this.db
111 .prepare("INSERT INTO terminal_terms (account_id, version, opted_in_at) VALUES (?, ?, ?) ON CONFLICT (account_id) DO UPDATE SET version = excluded.version, opted_in_at = excluded.opted_in_at")
112 .bind(personId, TERMINAL_TERMS_VERSION, at)
113 .run();
114 return at;
115 }
116
117 async insert(t: Pick<TerminalRow, "repo_id" | "person_id" | "agent_id" | "ref" | "tasks">): Promise<TerminalRow> {
118 const row: TerminalRow = { ...t, id: newId(), status: "starting", sandbox_id: null, created_at: now(), started_at: null, ended_at: null, end_reason: null, tasks: t.tasks };
119 await this.db
120 .prepare("INSERT INTO agent_terminals (id, repo_id, person_id, agent_id, ref, status, sandbox_id, created_at, tasks) VALUES (?, ?, ?, ?, ?, 'starting', NULL, ?, ?)")
121 .bind(row.id, row.repo_id, row.person_id, row.agent_id, row.ref, row.created_at, row.tasks)
122 .run();
123 return row;
124 }
125
126 async started(id: string, sandboxId: string): Promise<boolean> {
127 const r = await this.db.prepare("UPDATE agent_terminals SET status = 'running', sandbox_id = ?, started_at = ? WHERE id = ? AND status = 'starting'").bind(sandboxId, now(), id).run();
128 return (r.meta.changes ?? 0) > 0;
129 }
130
131 /** Marks it ended (once); the caller stops the sandbox. Returns whether this call ended it. */
132 async markEnded(id: string, reason: TerminalEndReason): Promise<boolean> {
133 const r = await this.db.prepare("UPDATE agent_terminals SET status = 'ended', ended_at = ?, end_reason = ? WHERE id = ? AND status != 'ended'").bind(now(), reason, id).run();
134 return (r.meta.changes ?? 0) > 0;
135 }
136
137 async running(repoId: string): Promise<TerminalRow[]> {
138 return (await this.db.prepare("SELECT * FROM agent_terminals WHERE repo_id = ? AND status != 'ended' ORDER BY created_at").bind(repoId).all<TerminalRow>()).results;
139 }
140}
141
142/**
143 * Opens a terminal for the person in this browser session. The sandbox starts in the project's
144 * alarm (it can take a minute); the page shows "Starting" until then.
145 */
146export async function openTerminal(c: AppContext, l: Loaded, input: { agent?: string; ref?: string; accept?: boolean; tasks?: boolean }): Promise<TerminalRow> {
147 const me = requirePersonSession(c);
148 if (!terminalsEnabled(c.env)) throw new HttpError(403, "terminals are switched off on this Diffinery");
149 if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)");
150 // The organization's agent policy (phase 5b): terminals, and tasks in them, allowed.
151 const dir = c.get("dir");
152 const refused = await terminalRefusal(c.env, l.repo, { tasks: !!input.tasks, person: { dir, account: me } });
153 if (refused) throw new HttpError(403, refused);
154 const agent = input.agent ? await dir.accountByLogin(input.agent) : null;
155 if (!agent || agent.kind !== "agent") throw new HttpError(422, "choose one of your agents");
156 try {
157 await taskCaller(dir, me, agent, l.repo);
158 } catch (e) {
159 if (e instanceof TaskAccessError) throw new HttpError(403, e.message);
160 throw e;
161 }
162 const terminals = new Terminals(c.env);
163 if (!(await terminals.termsAt(me.id))) {
164 if (!input.accept) throw new HttpError(422, "accept the notice first: you use your own provider account, under its terms");
165 await terminals.acceptTerms(me.id);
166 audit(c, me, l.repo, "terminal.terms", { version: TERMINAL_TERMS_VERSION });
167 }
168 const ref = (input.ref ?? "").trim() || l.repo.default_branch;
169 if (!(await l.stub.getRef(`refs/heads/${ref}`))) throw new HttpError(422, `the branch ${ref} doesn't exist`);
170 const open = await terminals.open(me.id);
171 if ((open.byRepo.get(l.repo.id) ?? 0) >= MAX_TERMINALS_PER_REPO) throw new HttpError(429, `you have ${MAX_TERMINALS_PER_REPO} terminals open in this repository, the most allowed; end one first`);
172 if (open.total >= MAX_TERMINALS) throw new HttpError(429, `you have ${MAX_TERMINALS} terminals open, the most allowed; end one first`);
173 // The account that owns the repository pays for the sandbox (lib/quotas.ts); say so now rather than
174 // after it fails to start. RepoDO.createSandbox checks again when it starts.
175 // Kill switches (lib/killswitch.ts) refuse new sandboxes, terminals included.
176 const block = blockedBy(await killSwitches(c.env, l.repo.id), "sandbox");
177 if (block) throw new HttpError(403, block);
178 const quota = await mayStart(c.env, l.repo.owner_id, "sandbox");
179 if (!quota.ok) throw new HttpError(429, quota.message);
180 const t = await terminals.insert({ repo_id: l.repo.id, person_id: me.id, agent_id: agent.id, ref, tasks: input.tasks ? 1 : 0 });
181 audit(c, me, l.repo, "terminal.open", { terminal: t.id, agent: agent.login, ref, tasks: !!input.tasks, runtimes: { "claude-code": RUNTIME_VERSIONS["claude-code"], codex: RUNTIME_VERSIONS.codex } });
182 await projectStub(c.env, l.repo.id).wakeTerminals(l.repo.id);
183 return t;
184}
185
186/**
187 * Ends a terminal: marks it ended, stops a task running in it, then stops its sandbox (which revokes its git
188 * credential). `project` is the repository's ProjectDO when the caller is that object itself.
189 */
190export async function endTerminal(env: Env, dir: Directory, t: TerminalRow, reason: TerminalEndReason, by?: Account, project?: { terminalEnded(id: string): Promise<void> }): Promise<void> {
191 if (!(await new Terminals(env).markEnded(t.id, reason))) return;
192 await (project ?? projectStub(env, t.repo_id)).terminalEnded(t.id).catch((e: unknown) => console.error("stopping the terminal's task failed", e));
193 if (t.sandbox_id) await env.REPO.get(env.REPO.idFromName(t.repo_id)).closeTaskSandbox(t.sandbox_id).catch((e) => console.error("terminal sandbox close failed", e));
194 const repo = await dir.repoById(t.repo_id).catch(() => null);
195 const actor = by ?? (await dir.account(t.person_id).catch(() => null));
196 await dir
197 .audit({ actorId: actor?.id ?? t.person_id, actorLogin: actor?.login ?? "unknown", action: "terminal.end", target: repo ? `${repo.owner_login}/${repo.name}` : t.repo_id, detail: { terminal: t.id, reason } })
198 .catch((e) => console.error("audit failed", e));
199}
200
201function audit(c: AppContext, me: Account, repo: Repo, action: string, detail: Record<string, unknown>): void {
202 c.executionCtx.waitUntil(
203 c
204 .get("dir")
205 .audit({ actorId: me.id, actorLogin: me.login, action, target: `${repo.owner_login}/${repo.name}`, detail })
206 .catch((e) => console.error("audit failed", e)),
207 );
208}
209
210// ---------------------------------------------------------------- 4b: tasks in a terminal
211
212/**
213 * The runtimes a terminal runs tasks with, signed in there: Claude Code, and Codex on private repositories only
214 * (OpenAI allows signing in with a ChatGPT plan only there).
215 */
216export function terminalRuntimes(repo: Repo): RuntimeId[] {
217 return repo.visibility === "public" ? ["claude-code"] : ["claude-code", "codex"];
218}
219
220/** The person's own open terminal in this repository, with the opt-in in force, checked again at every dispatch. */
221async function dispatchTerminal(c: AppContext, l: Loaded, me: Account, id: string | undefined, runtime: RuntimeId, agentId?: string): Promise<{ t: TerminalRow; to: { id: string; sandboxId: string } }> {
222 if (!terminalsEnabled(c.env)) throw new HttpError(403, "terminals are switched off on this Diffinery");
223 const refused = await terminalRefusal(c.env, l.repo, { tasks: true, runtime, person: { dir: c.get("dir"), account: me } });
224 if (refused) throw new HttpError(403, refused);
225 const terminals = new Terminals(c.env);
226 const t = await terminals.mine(terminalId(id), me, l.repo);
227 if (t.status !== "running" || !t.sandbox_id) throw new HttpError(409, "the terminal isn't running");
228 if (!t.tasks) throw new HttpError(409, "this terminal was opened without agent tasks: open one with \"Will run agent tasks\" to give it one");
229 if (!(await terminals.termsAt(me.id))) throw new HttpError(403, "accept the notice for terminals first: tasks there use your own provider account");
230 // Kill switches (lib/killswitch.ts) stop sandboxes here: the terminal's is ending, and starts no task meanwhile.
231 const block = blockedBy(await killSwitches(c.env, l.repo.id), "sandbox");
232 if (block) throw new HttpError(403, block);
233 if (agentId && t.agent_id !== agentId) throw new HttpError(422, "this terminal acts as a different agent than the task's: open one with the task's agent");
234 if (await projectStub(c.env, l.repo.id).terminalTask(t.id)) throw new HttpError(409, "your terminal is already running a task; one at a time");
235 return { t, to: { id: t.id, sandboxId: t.sandbox_id } };
236}
237
238function terminalRuntime(l: Loaded, runtime: unknown): RuntimeId {
239 const ok = terminalRuntimes(l.repo);
240 if (typeof runtime !== "string" || !ok.includes(runtime as RuntimeId)) throw new HttpError(422, l.repo.visibility === "public" ? "a public repository's terminal runs tasks with Claude Code" : "a terminal runs tasks with Claude Code or Codex");
241 return runtime as RuntimeId;
242}
243
244/**
245 * Start a task in the person's terminal (phase 4b): only from their own click on the terminal's page. The CLI signed
246 * in there does the work, on their own subscription, as the terminal's agent; Diffinery pushes and opens the pull
247 * request as for any task. Its checks don't start fix runs by themselves.
248 */
249export async function startTaskInTerminal(c: AppContext, l: Loaded, id: string | undefined, input: { goal?: unknown; runtime?: unknown }): Promise<TaskView> {
250 const me = requirePersonSession(c);
251 if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)");
252 const goal = typeof input.goal === "string" ? input.goal.trim() : "";
253 if (!goal) throw new HttpError(422, "write the goal");
254 if (goal.length > MAX_GOAL) throw new HttpError(422, `the goal is longer than ${MAX_GOAL} characters`);
255 const runtime = terminalRuntime(l, input.runtime);
256 const { t, to } = await dispatchTerminal(c, l, me, id, runtime);
257 const dir = c.get("dir");
258 const agent = await dir.account(t.agent_id);
259 if (!agent) throw new HttpError(403, "the terminal's agent no longer exists");
260 try {
261 await taskCaller(dir, me, agent, l.repo);
262 } catch (e) {
263 if (e instanceof TaskAccessError) throw new HttpError(403, e.message);
264 throw e;
265 }
266 if (!(await l.stub.getRef(`refs/heads/${t.ref}`))) throw new HttpError(422, `the branch ${t.ref} doesn't exist any more`);
267 const project = projectStub(c.env, l.repo.id);
268 if ((await project.projectSettings()).planFirst === "required") throw new HttpError(422, "this project requires a plan first, which is written in a sandbox of its own: start the task from New task");
269 const task = await project.create({ repoId: l.repo.id, goal, runtime, command: null, agent: { id: agent.id, login: agent.login }, person: { id: me.id, login: me.login }, base: t.ref, providers: [], planFirst: false, attempts: 1, fixChecks: false, land: false, terminal: to });
270 audit(c, me, l.repo, "task.start", { agent: agent.login, runtime, terminal: t.id, fix_checks: false, land: false });
271 audit(c, me, l.repo, "terminal.task", { terminal: t.id, task: task.number, runtime, version: RUNTIME_VERSIONS[runtime as "claude-code" | "codex"] });
272 return task;
273}
274
275/** Run an existing task again in the person's terminal (phase 4b): only from their own click on the task's page. */
276export async function runTaskInTerminal(c: AppContext, l: Loaded, number: number, id: string | undefined, message: unknown): Promise<TaskView> {
277 const { person, task } = await starter(c, l, number);
278 if (task.kind !== "task") throw new HttpError(409, "only agent tasks run in a terminal");
279 const runtime = terminalRuntime(l, task.runtime);
280 const text = typeof message === "string" ? message.trim() : "";
281 if (text.length > MAX_GOAL) throw new HttpError(422, `the message is longer than ${MAX_GOAL} characters`);
282 const { t, to } = await dispatchTerminal(c, l, person, id, runtime, task.agent_id);
283 const out = await projectStub(c.env, l.repo.id).runInTerminal(number, to, text || null);
284 audit(c, person, l.repo, "terminal.task", { terminal: t.id, task: number, runtime, version: RUNTIME_VERSIONS[runtime as "claude-code" | "codex"] });
285 return out;
286}
287
288/** The person's open terminal a task could run in now, for its page's Run in my terminal button; null when none. */
289export async function terminalForTask(env: Env, me: Account, repo: Repo, task: TaskView): Promise<string | null> {
290 if (!terminalsEnabled(env) || task.kind !== "task" || task.person_id !== me.id || !terminalRuntimes(repo).includes(task.runtime)) return null;
291 if ((task.attempts > 1 && task.picked === null) || task.plan_state === "planning" || task.plan_state === "waiting") return null;
292 const terminals = new Terminals(env);
293 const open = (await terminals.list(me.id, repo.id)).filter((t) => t.status === "running" && t.tasks && t.agent_id === task.agent_id);
294 if (!open.length || !(await terminals.termsAt(me.id))) return null;
295 const project = projectStub(env, repo.id);
296 for (const t of open) if ((await project.terminalTask(t.id)) === null) return t.id;
297 return null;
298}