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.

238 lines · 12.6 KBRaw History
1import { requireAccount, type AppContext } from "../auth/principal.js";
2import type { Env } from "../env.js";
3import type { loadRepo } from "../http/common.js";
4import type { Account, Directory } from "../lib/directory.js";
5import { HttpError } from "../lib/util.js";
6import { atLeast, type Caller } from "../repo/types.js";
7import { mayDriveAgent, taskCaller, TaskAccessError } from "./access.js";
8import { ModelKeys, PROVIDERS, type Provider } from "./keys.js";
9import type { TaskRow, TaskView } from "./ProjectDO.js";
10import { isRuntime, MAX_COMMAND, MAX_GOAL, RUNTIMES, type RuntimeId, type TranscriptLine } from "./runtimes.js";
11
12/**
13 * Who may do what with agent tasks. Shared by the REST API, MCP and the web.
14 *
15 * - Reading tasks and transcripts: write access to the repository or more.
16 * - Starting, sending a message and retrying: only a person, in a browser
17 * session (each run spends their model key), and for an existing task only
18 * the person who started it. No token, agent, job or sandbox can.
19 * - Stopping: the person who started it, its agent, or a repository admin.
20 */
21
22type Loaded = Awaited<ReturnType<typeof loadRepo>>;
23
24export const projectStub = (env: Env, repoId: string) => env.PROJECT.get(env.PROJECT.idFromName(repoId));
25
26/** Tasks are visible with write access or more: transcripts show an agent's reasoning and command output. */
27export function requireTaskReader(l: Loaded): void {
28 if (!atLeast(l.caller.role, "write")) throw new HttpError(404, "not found");
29}
30
31/** A person signed in in a browser (or the local dev sign-in): the only one who can spend their model key. */
32export function requirePersonSession(c: AppContext): Account {
33 const p = c.get("principal");
34 if (p.type === "anonymous") throw new HttpError(401, "sign in required");
35 if (p.type !== "account" || (p.via !== "session" && p.via !== "dev")) throw new HttpError(403, "starting or steering an agent task needs a browser session: each run spends your own model key, so no token can do it");
36 const me = requireAccount(c);
37 if (me.kind !== "user") throw new HttpError(403, "only people can start agent tasks");
38 return me;
39}
40
41export function taskNumber(s: string | undefined): number {
42 const n = Number(s);
43 if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "task not found");
44 return n;
45}
46
47export async function getTask(c: AppContext, l: Loaded, number: number): Promise<TaskView> {
48 requireTaskReader(l);
49 const t = await projectStub(c.env, l.repo.id).get(number);
50 if (!t) throw new HttpError(404, "task not found");
51 return t;
52}
53
54/** The providers a run gets, checked against the runtime's needs and the person's keys. */
55async function providersFor(env: Env, person: Account, runtime: RuntimeId): Promise<Provider[]> {
56 const have = await new ModelKeys(env).providers(person.id);
57 for (const p of RUNTIMES[runtime].needs) {
58 if (!have.has(p)) throw new HttpError(422, `${RUNTIMES[runtime].name} needs your ${PROVIDERS[p].name} API key: add it in Settings → Model access`);
59 }
60 return runtime === "custom" ? [...have] : RUNTIMES[runtime].needs;
61}
62
63async function asTaskCaller(c: AppContext, person: Account, agent: Account, l: Loaded): Promise<Caller> {
64 try {
65 return await taskCaller(c.get("dir"), person, agent, l.repo);
66 } catch (e) {
67 if (e instanceof TaskAccessError) throw new HttpError(403, e.message);
68 throw e;
69 }
70}
71
72export interface StartInput {
73 goal?: unknown;
74 runtime?: unknown;
75 agent?: unknown;
76 base?: unknown;
77 command?: unknown;
78}
79
80export async function startTask(c: AppContext, l: Loaded, input: StartInput): Promise<TaskView> {
81 const person = requirePersonSession(c);
82 if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)");
83 const goal = typeof input.goal === "string" ? input.goal.trim() : "";
84 if (!goal) throw new HttpError(422, "`goal` is required");
85 if (goal.length > MAX_GOAL) throw new HttpError(422, `\`goal\` is longer than ${MAX_GOAL} characters`);
86 const runtime = typeof input.runtime === "string" ? input.runtime : "claude-code";
87 if (!isRuntime(runtime)) throw new HttpError(422, "`runtime` must be claude-code, codex or custom");
88 const command = runtime === "custom" ? (typeof input.command === "string" ? input.command.trim() : "") : null;
89 if (runtime === "custom" && !command) throw new HttpError(422, "`command` is required for the custom runtime");
90 if (command && command.length > MAX_COMMAND) throw new HttpError(422, `\`command\` is longer than ${MAX_COMMAND} characters`);
91 const agentLogin = typeof input.agent === "string" ? input.agent.trim() : "";
92 const agent = agentLogin ? await c.get("dir").accountByLogin(agentLogin) : null;
93 if (!agent || agent.kind !== "agent") throw new HttpError(422, "`agent` must be the login of one of your agents");
94 await asTaskCaller(c, person, agent, l);
95 const base = typeof input.base === "string" && input.base.trim() ? input.base.trim() : l.repo.default_branch;
96 if (!(await l.stub.getRef(`refs/heads/${base}`))) throw new HttpError(422, `branch ${base} doesn't exist`);
97 const providers = await providersFor(c.env, person, runtime);
98 const t = await projectStub(c.env, l.repo.id).create({ repoId: l.repo.id, goal, runtime, command, agent: { id: agent.id, login: agent.login }, person: { id: person.id, login: person.login }, base, providers });
99 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.start", target: `${l.repo.owner_login}/${l.repo.name}#${t.number}`, detail: { agent: agent.login, runtime } }));
100 return t;
101}
102
103/** The person who started the task, in a browser session, still allowed to drive its agent here. */
104async function starter(c: AppContext, l: Loaded, number: number): Promise<{ person: Account; task: TaskView }> {
105 const person = requirePersonSession(c);
106 const task = await getTask(c, l, number);
107 if (task.person_id !== person.id) throw new HttpError(403, `only ${task.person_login}, who started this task, can steer or retry it: each run spends their model key`);
108 if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)");
109 const agent = await c.get("dir").account(task.agent_id);
110 if (!agent) throw new HttpError(403, "the task's agent no longer exists");
111 await asTaskCaller(c, person, agent, l);
112 return { person, task };
113}
114
115export async function messageTask(c: AppContext, l: Loaded, number: number, text: unknown): Promise<TaskView> {
116 const body = typeof text === "string" ? text.trim() : "";
117 if (!body) throw new HttpError(422, "`body` is required");
118 if (body.length > MAX_GOAL) throw new HttpError(422, `\`body\` is longer than ${MAX_GOAL} characters`);
119 const { person, task } = await starter(c, l, number);
120 const t = await projectStub(c.env, l.repo.id).message(number, body, await providersFor(c.env, person, task.runtime));
121 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.message", target: `${l.repo.owner_login}/${l.repo.name}#${number}` }));
122 return t;
123}
124
125export async function retryTask(c: AppContext, l: Loaded, number: number): Promise<TaskView> {
126 const { person, task } = await starter(c, l, number);
127 const t = await projectStub(c.env, l.repo.id).retry(number, await providersFor(c.env, person, task.runtime));
128 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.retry", target: `${l.repo.owner_login}/${l.repo.name}#${number}` }));
129 return t;
130}
131
132/** Whether `caller` can stop the task: its person, its agent, or a repository admin. */
133export const mayStop = (caller: Caller, t: TaskRow) => caller.id === t.person_id || caller.id === t.agent_id || atLeast(caller.role, "admin");
134
135export async function stopTask(c: AppContext, l: Loaded, number: number): Promise<TaskView> {
136 const task = await getTask(c, l, number);
137 if (!mayStop(l.caller, task)) throw new HttpError(403, "only the person who started the task, its agent or a repository admin can stop it");
138 const t = await projectStub(c.env, l.repo.id).stop(number, l.caller.login);
139 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: l.caller.id, actorLogin: l.caller.login, onBehalfOf: l.caller.onBehalfOf, action: "task.stop", target: `${l.repo.owner_login}/${l.repo.name}#${number}` }));
140 return t;
141}
142
143/** Agents a person can run tasks as, here: ones they manage that have write access to the repository. */
144export async function drivableAgents(c: AppContext, person: Account, l: Loaded): Promise<Account[]> {
145 const dir = c.get("dir");
146 const orgs = (await dir.orgsFor(person.id)).filter((o) => o.role === "owner" || o.role === "admin").map((o) => o.id);
147 const agents = await dir.agentsOwnedBy([person.id, ...orgs]);
148 const out: Account[] = [];
149 for (const a of agents) {
150 if (!(await mayDriveAgent(dir, person, a))) continue;
151 if (atLeast(await dir.roleFor(a, l.repo), "write")) out.push(a);
152 }
153 return out;
154}
155
156// ---------------------------------------------------------------- JSON
157
158export function taskJson(base: string, repoFull: string, t: TaskRow | TaskView) {
159 return {
160 number: t.number,
161 title: t.title,
162 goal: t.goal,
163 state: t.state,
164 runtime: t.runtime,
165 ...(t.command ? { command: t.command } : {}),
166 agent: { login: t.agent_login, type: "Bot" },
167 started_by: { login: t.person_login, type: "User" },
168 base: t.base,
169 branch: t.branch,
170 pull_request: t.pr_number ? { number: t.pr_number, html_url: `${base}/${repoFull}/pull/${t.pr_number}` } : null,
171 queued_message: t.queued ?? null,
172 error: t.error,
173 html_url: `${base}/${repoFull}/tasks/${t.number}`,
174 created_at: t.created_at,
175 updated_at: t.updated_at,
176 ...("runs" in t ? { runs: t.runs.map((r) => ({ number: r.seq, kind: r.kind, message: r.message, state: r.step, exit_code: r.exit_code, error: r.error, started_at: r.started_at, ended_at: r.ended_at })) } : {}),
177 };
178}
179
180export const lineJson = (l: TranscriptLine) => ({ seq: l.seq, run: l.run, at: l.at, stream: l.stream, text: l.text });
181
182export interface MyTask {
183 repository: string;
184 repoId: string;
185 number: number;
186 title: string;
187 runtime: RuntimeId;
188 state: TaskRow["state"];
189 agent: string;
190 prNumber: number | null;
191 createdAt: string;
192 updatedAt: string;
193}
194
195/**
196 * Tasks a person started (or, for an agent, tasks it runs), newest activity
197 * first, from the D1 index, in repositories where they still have write access.
198 */
199export async function tasksFor(env: Env, dir: Directory, me: Account, opts: { repoIds?: string[] | null; since?: string; activeOrSince?: boolean; limit?: number } = {}): Promise<MyTask[]> {
200 const col = me.kind === "agent" ? "t.agent_id" : "t.started_by";
201 const since = opts.since ?? "";
202 const r = await env.DB.prepare(
203 `SELECT t.repo_id, t.number, t.title, t.runtime, t.state, t.pr_number, t.created_at, t.updated_at, o.login AS owner_login, r.name AS repo_name, ag.login AS agent_login
204 FROM agent_tasks t JOIN repos r ON r.id = t.repo_id JOIN accounts o ON o.id = r.owner_id JOIN accounts ag ON ag.id = t.agent_id
205 WHERE ${col} = ? AND (t.updated_at >= ?${opts.activeOrSince ? " OR t.state = 'running'" : ""}) ORDER BY t.updated_at DESC LIMIT ?`,
206 )
207 .bind(me.id, since, Math.min(opts.limit ?? 50, 100))
208 .all<{ repo_id: string; number: number; title: string; runtime: RuntimeId; state: TaskRow["state"]; pr_number: number | null; created_at: string; updated_at: string; owner_login: string; repo_name: string; agent_login: string }>();
209 const allowed = new Map<string, boolean>();
210 const out: MyTask[] = [];
211 for (const t of r.results) {
212 if (opts.repoIds && !opts.repoIds.includes(t.repo_id)) continue;
213 if (!allowed.has(t.repo_id)) {
214 const repo = await dir.repoById(t.repo_id);
215 allowed.set(t.repo_id, !!repo && atLeast(await dir.roleFor(me, repo), "write"));
216 }
217 if (!allowed.get(t.repo_id)) continue;
218 out.push({ repository: `${t.owner_login}/${t.repo_name}`, repoId: t.repo_id, number: t.number, title: t.title, runtime: t.runtime, state: t.state, agent: t.agent_login, prNumber: t.pr_number, createdAt: t.created_at, updatedAt: t.updated_at });
219 }
220 return out;
221}
222
223export const myTaskJson = (base: string, t: MyTask) => ({
224 repository: t.repository,
225 number: t.number,
226 title: t.title,
227 state: t.state,
228 runtime: t.runtime,
229 agent: { login: t.agent, type: "Bot" },
230 pull_request: t.prNumber ? { number: t.prNumber, html_url: `${base}/${t.repository}/pull/${t.prNumber}` } : null,
231 html_url: `${base}/${t.repository}/tasks/${t.number}`,
232 created_at: t.createdAt,
233 updated_at: t.updatedAt,
234});
235
236export async function myTasks(c: AppContext, me: Account, repoIds: string[] | null) {
237 return (await tasksFor(c.env, c.get("dir"), me, { repoIds })).map((t) => myTaskJson(c.env.PUBLIC_URL.replace(/\/$/, ""), t));
238}