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.

342 lines · 18.0 KBRaw History
1import { Hono } from "hono";
2import type { Env } from "../env.js";
3import { requireAccount, requireRecentSignIn, type AppContext, type AppVars } from "../auth/principal.js";
4import { loadRepo } from "../http/common.js";
5import { HttpError } from "../lib/util.js";
6import { isProvider, ModelKeys, type ModelKeyInfo } from "./keys.js";
7import { approvePlan, attemptJson, compareAttempts, getTask, pickAttempt, setTaskOptions, lineJson, messageTask, myTasks, projectStub, requirePersonSession, requireTaskReader, retryTask, startTask, stopTask, taskJson, taskNumber, type StartInput } from "./tasks.js";
8import { askForReview, findingJson, reviewSettings, reviewSettingsJson, saveReviewSettings, type SettingsInput } from "./reviews.js";
9import { gatherToday, todayJson } from "./today.js";
10import { orgUsageReport, orgUsageReportJson, usageDays, usageReport, usageReportJson } from "./ledger.js";
11import { orgPolicy, orgSpend, parsePolicy, policyJson, savePolicy, spendMonth, type PolicyInput } from "./org-policy.js";
12import { boardJson, coordinatorJson, coordinatorSummaryJson, handlePost, splitPlan, loadBoard, memoryJson, postJson, postToProject, saveCoordinator, saveMemory, saveProjectSettings, settingsJson, type CoordinatorInput } from "./projects.js";
13
14async function jsonBody<T>(c: AppContext): Promise<T> {
15 try {
16 return (await c.req.json()) as T;
17 } catch {
18 throw new HttpError(400, "request body must be JSON");
19 }
20}
21
22/** Diffinery-only REST endpoints for agentic work, mounted under /api/v3 (see http/api.ts). */
23export const agenticApi = new Hono<{ Bindings: Env; Variables: AppVars }>();
24
25/** The caller's token restriction to repositories, if any. */
26export const tokenRepoIds = (c: AppContext): string[] | null => {
27 const p = c.get("principal");
28 return p.type === "account" ? p.repoIds : null;
29};
30
31agenticApi.get("/diffinery/today", async (c) => {
32 const me = requireAccount(c);
33 const today = await gatherToday(c.env, c.get("dir"), me, { repoIds: tokenRepoIds(c) });
34 return c.json(todayJson(c.env.PUBLIC_URL.replace(/\/$/, ""), today));
35});
36
37// ---------------------------------------------------------------- model access (agentic/keys.ts)
38
39const keyJson = (k: ModelKeyInfo) => ({ provider: k.provider, hint: k.hint, opted_in_at: k.optedInAt, ...(k.optedInBy ? { opted_in_by: k.optedInBy } : {}), created_at: k.createdAt, updated_at: k.updatedAt });
40
41agenticApi.get("/diffinery/model-keys", async (c) => c.json((await new ModelKeys(c.env).list(requireAccount(c).id)).map(keyJson)));
42
43/** Phase 5a: the model usage the caller paid for, across repositories, over the last `days` (1 to 90, default 30). */
44agenticApi.get("/diffinery/model-usage", async (c) => {
45 const report = await usageReport(c.env, requireAccount(c).id, usageDays(c.req.query("days")));
46 // A token limited to some repositories sees only their lines.
47 const only = tokenRepoIds(c);
48 if (only) report.lines = report.lines.filter((l) => only.includes(l.repoId));
49 return c.json(usageReportJson(report));
50});
51
52agenticApi.put("/diffinery/model-keys/:provider", async (c) => {
53 // A key is spent by tasks this person starts; only they, in a browser, can set it.
54 const me = requirePersonSession(c);
55 const provider = c.req.param("provider");
56 if (!isProvider(provider)) throw new HttpError(404, "unknown provider: use anthropic, openai or openrouter");
57 // Swapping in another key sends the person's later prompts and code to that key's account: a sign-in from the last 2 hours, as for organizations' keys.
58 requireRecentSignIn(c, "/settings/model-access");
59 const b = await jsonBody<{ key?: unknown; accept_terms?: unknown }>(c);
60 if (typeof b.key !== "string") throw new HttpError(422, "`key` is required");
61 const k = await new ModelKeys(c.env).set(me, provider, b.key, b.accept_terms === true);
62 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "model_key.set", target: provider, detail: { hint: k.hint, opted_in_at: k.optedInAt } }));
63 return c.json(keyJson(k));
64});
65
66agenticApi.delete("/diffinery/model-keys/:provider", async (c) => {
67 const me = requirePersonSession(c);
68 const provider = c.req.param("provider");
69 if (!isProvider(provider)) throw new HttpError(404, "unknown provider: use anthropic, openai or openrouter");
70 if (!(await new ModelKeys(c.env).delete(me.id, provider))) throw new HttpError(404, "no key for that provider");
71 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "model_key.delete", target: provider }));
72 return c.body(null, 204);
73});
74
75// ---------------------------------------------------------------- organizations' agent policy and keys (phase 5b, org-policy.ts)
76
77/**
78 * An organization's agent settings: its owners only (and site admins, audited as themselves). A token limited to
79 * repositories can't reach them. Writes need a browser session; key writes also a sign-in from the last 2 hours.
80 */
81async function orgOwner(c: AppContext, need: "read" | "write" | "key") {
82 const me = need === "read" ? requireAccount(c) : requirePersonSession(c);
83 if (tokenRepoIds(c)) throw new HttpError(403, "a token limited to repositories can't use an organization's agent settings");
84 const dir = c.get("dir");
85 const org = await dir.accountByLogin(c.req.param("org") ?? "");
86 if (!org || org.kind !== "org" || org.status === "disabled") throw new HttpError(404, "not found");
87 const role = await dir.orgRole(org.id, me.id);
88 if (role !== "owner" && !me.site_admin) throw new HttpError(role ? 403 : 404, role ? "organization owners only" : "not found");
89 if (need === "key") requireRecentSignIn(c, `/${org.login}?tab=settings`);
90 return { me, org, dir, asSiteAdmin: role !== "owner" };
91}
92
93agenticApi.get("/orgs/:org/diffinery/agent-policy", async (c) => {
94 const { org } = await orgOwner(c, "read");
95 return c.json(policyJson(await orgPolicy(c.env, org.id)));
96});
97
98agenticApi.put("/orgs/:org/diffinery/agent-policy", async (c) => {
99 const { me, org, dir } = await orgOwner(c, "write");
100 const p = parsePolicy(await jsonBody<PolicyInput>(c), await orgPolicy(c.env, org.id));
101 return c.json(policyJson(await savePolicy(c.env, dir, org, me, p, (x) => c.executionCtx.waitUntil(x))));
102});
103
104agenticApi.get("/orgs/:org/diffinery/model-keys", async (c) => {
105 const { org } = await orgOwner(c, "read");
106 return c.json((await new ModelKeys(c.env).list(org.id)).map(keyJson));
107});
108
109agenticApi.put("/orgs/:org/diffinery/model-keys/:provider", async (c) => {
110 const { me, org, dir, asSiteAdmin } = await orgOwner(c, "key");
111 const provider = c.req.param("provider");
112 if (!isProvider(provider)) throw new HttpError(404, "unknown provider: use anthropic, openai or openrouter");
113 const b = await jsonBody<{ key?: unknown; accept_terms?: unknown }>(c);
114 if (typeof b.key !== "string") throw new HttpError(422, "`key` is required");
115 const k = await new ModelKeys(c.env).set(org, provider, b.key, b.accept_terms === true, me);
116 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.model_key.set", target: org.login, detail: { provider, hint: k.hint, opted_in_at: k.optedInAt, ...(asSiteAdmin ? { site_admin: true } : {}) } }));
117 return c.json(keyJson(k));
118});
119
120agenticApi.delete("/orgs/:org/diffinery/model-keys/:provider", async (c) => {
121 const { me, org, dir, asSiteAdmin } = await orgOwner(c, "key");
122 const provider = c.req.param("provider");
123 if (!isProvider(provider)) throw new HttpError(404, "unknown provider: use anthropic, openai or openrouter");
124 if (!(await new ModelKeys(c.env).delete(org.id, provider))) throw new HttpError(404, "no key for that provider");
125 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.model_key.delete", target: org.login, detail: { provider, ...(asSiteAdmin ? { site_admin: true } : {}) } }));
126 return c.body(null, 204);
127});
128
129/** The runs the organization's keys paid for over the last `days`, and each person's tokens this month against the allowance. */
130agenticApi.get("/orgs/:org/diffinery/model-usage", async (c) => {
131 const { org } = await orgOwner(c, "read");
132 const [report, policy, spend] = await Promise.all([orgUsageReport(c.env, org.id, usageDays(c.req.query("days"))), orgPolicy(c.env, org.id), orgSpend(c.env, org.id)]);
133 return c.json({
134 ...orgUsageReportJson(report),
135 month: { month: spendMonth(), member_monthly_tokens: policy.memberMonthlyTokens, people: spend.map((x) => ({ person: x.login, tokens: x.tokens })) },
136 });
137});
138
139// ---------------------------------------------------------------- agent tasks (agentic/tasks.ts)
140
141const base = (c: AppContext) => c.env.PUBLIC_URL.replace(/\/$/, "");
142const full = (l: { repo: { owner_login: string; name: string } }) => `${l.repo.owner_login}/${l.repo.name}`;
143const repoOf = (c: AppContext, min: "read" | "write" | "admin" = "read") => loadRepo(c, c.req.param("owner")!, c.req.param("repo")!, min);
144
145agenticApi.get("/repos/:owner/:repo/diffinery/tasks", async (c) => {
146 const l = await repoOf(c);
147 requireTaskReader(l);
148 const state = c.req.query("state");
149 const tasks = await projectStub(c.env, l.repo.id).list({ limit: Number(c.req.query("per_page")) || 50, state: state === "queued" || state === "running" || state === "ready" || state === "failed" || state === "stopped" || state === "active" ? state : undefined });
150 return c.json(tasks.map((t) => taskJson(base(c), full(l), t)));
151});
152
153agenticApi.post("/repos/:owner/:repo/diffinery/tasks", async (c) => {
154 requirePersonSession(c);
155 const l = await repoOf(c, "write");
156 const t = await startTask(c, l, await jsonBody<StartInput>(c));
157 return c.json(taskJson(base(c), full(l), t), 201);
158});
159
160agenticApi.get("/repos/:owner/:repo/diffinery/tasks/:number", async (c) => {
161 const l = await repoOf(c);
162 return c.json(taskJson(base(c), full(l), await getTask(c, l, taskNumber(c.req.param("number")))));
163});
164
165agenticApi.patch("/repos/:owner/:repo/diffinery/tasks/:number", async (c) => {
166 const l = await repoOf(c);
167 return c.json(taskJson(base(c), full(l), await setTaskOptions(c, l, taskNumber(c.req.param("number")), await jsonBody<{ fix_checks?: unknown; land?: unknown }>(c))));
168});
169
170agenticApi.get("/repos/:owner/:repo/diffinery/tasks/:number/transcript", async (c) => {
171 const l = await repoOf(c);
172 const n = taskNumber(c.req.param("number"));
173 await getTask(c, l, n);
174 const lines = await projectStub(c.env, l.repo.id).lines(n, Number(c.req.query("after")) || 0, Number(c.req.query("per_page")) || 2000);
175 return c.json(lines.map(lineJson));
176});
177
178agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/messages", async (c) => {
179 requirePersonSession(c);
180 const l = await repoOf(c, "write");
181 const b = await jsonBody<{ body?: unknown }>(c);
182 return c.json(taskJson(base(c), full(l), await messageTask(c, l, taskNumber(c.req.param("number")), b.body)), 201);
183});
184
185agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/retry", async (c) => {
186 requirePersonSession(c);
187 const l = await repoOf(c, "write");
188 return c.json(taskJson(base(c), full(l), await retryTask(c, l, taskNumber(c.req.param("number")))), 201);
189});
190
191agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/stop", async (c) => {
192 const l = await repoOf(c, "write");
193 return c.json(taskJson(base(c), full(l), await stopTask(c, l, taskNumber(c.req.param("number")))));
194});
195
196agenticApi.get("/diffinery/tasks", async (c) => {
197 const me = requireAccount(c);
198 return c.json(await myTasks(c, me, tokenRepoIds(c)));
199});
200
201// ---------------------------------------------------------------- agent review (agentic/reviews.ts)
202
203agenticApi.get("/repos/:owner/:repo/diffinery/agent-review", async (c) => {
204 const l = await repoOf(c);
205 requireTaskReader(l);
206 return c.json(reviewSettingsJson(await reviewSettings(c.env, l.repo.id)));
207});
208
209agenticApi.put("/repos/:owner/:repo/diffinery/agent-review", async (c) => {
210 // Every review spends the key of the admin who turns reviews on: only they, in a browser, can.
211 requirePersonSession(c);
212 const l = await repoOf(c, "admin");
213 const b = await jsonBody<SettingsInput & { accept_terms?: unknown }>(c);
214 const s = await saveReviewSettings(c, l, { ...b, accept: b.accept_terms });
215 const me = requireAccount(c);
216 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "review.settings", target: full(l), detail: { enabled: s?.enabled ?? false, agent: s?.agent.login, runtime: s?.runtime, trigger: s?.trigger, autofix: s?.autofix, daily_limit: s?.dailyLimit, tokens_per_review: s?.tokensPerReview } }));
217 return c.json(reviewSettingsJson(s));
218});
219
220agenticApi.get("/repos/:owner/:repo/pulls/:number/diffinery/agent-review", async (c) => {
221 const l = await repoOf(c);
222 requireTaskReader(l);
223 const n = Number(c.req.param("number"));
224 if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "not found");
225 const stub = projectStub(c.env, l.repo.id);
226 const [t, findings] = await Promise.all([stub.reviewTask(n), stub.findings(n)]);
227 return c.json({ task: t ? taskJson(base(c), full(l), t) : null, reviewed_sha: t?.reviewed_sha ?? null, findings: findings.map((f) => findingJson(base(c), full(l), f)) });
228});
229
230agenticApi.post("/repos/:owner/:repo/pulls/:number/diffinery/agent-review", async (c) => {
231 const l = await repoOf(c, "write");
232 const n = Number(c.req.param("number"));
233 if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "not found");
234 const out = await askForReview(c, l, n);
235 return c.json({ status: out.status, task: out.task ? `${base(c)}/${full(l)}/tasks/${out.task}` : null }, 202);
236});
237
238// ---------------------------------------------------------------- the project (agentic/projects.ts)
239
240agenticApi.get("/repos/:owner/:repo/diffinery/project", async (c) => {
241 const l = await repoOf(c);
242 requireTaskReader(l);
243 return c.json(boardJson(base(c), full(l), await loadBoard(c, l)));
244});
245
246agenticApi.get("/repos/:owner/:repo/diffinery/project/posts", async (c) => {
247 const l = await repoOf(c);
248 requireTaskReader(l);
249 const posts = await projectStub(c.env, l.repo.id).posts(Number(c.req.query("after")) || 0, Number(c.req.query("per_page")) || 100);
250 return c.json(posts.map((p) => postJson(base(c), full(l), p)));
251});
252
253agenticApi.post("/repos/:owner/:repo/diffinery/project/posts", async (c) => {
254 const l = await repoOf(c);
255 requireTaskReader(l);
256 const b = await jsonBody<{ body?: unknown; coordinator?: unknown }>(c);
257 if (b.coordinator !== undefined && typeof b.coordinator !== "boolean") throw new HttpError(422, "`coordinator` must be true or false");
258 const { post, handled } = await postToProject(c, l, b.body, { coordinator: b.coordinator as boolean | undefined });
259 return c.json({ ...postJson(base(c), full(l), post), coordinator: handled }, 201);
260});
261
262agenticApi.get("/repos/:owner/:repo/diffinery/tasks/:number/attempts", async (c) => {
263 const l = await repoOf(c);
264 const n = taskNumber(c.req.param("number"));
265 await getTask(c, l, n);
266 return c.json((await compareAttempts(c, l, n)).map((a) => attemptJson(base(c), full(l), a)));
267});
268
269agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/attempts/:attempt/pick", async (c) => {
270 const l = await repoOf(c);
271 const k = Number(c.req.param("attempt"));
272 if (!Number.isInteger(k) || k < 1) throw new HttpError(404, "no such attempt");
273 return c.json(taskJson(base(c), full(l), await pickAttempt(c, l, taskNumber(c.req.param("number")), k)), 202);
274});
275
276agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/plan/approve", async (c) => {
277 const l = await repoOf(c);
278 return c.json(taskJson(base(c), full(l), await approvePlan(c, l, taskNumber(c.req.param("number")))), 202);
279});
280
281agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/plan/split", async (c) => {
282 const l = await repoOf(c);
283 return c.json(await splitPlan(c, l, taskNumber(c.req.param("number"))), 202);
284});
285
286agenticApi.post("/repos/:owner/:repo/diffinery/project/posts/:n/handle", async (c) => {
287 const l = await repoOf(c);
288 requireTaskReader(l);
289 const n = Number(c.req.param("n"));
290 if (!Number.isInteger(n) || n <= 0) throw new HttpError(404, "no such post");
291 return c.json(await handlePost(c, l, n), 202);
292});
293
294agenticApi.get("/repos/:owner/:repo/diffinery/project/coordinator", async (c) => {
295 const l = await repoOf(c);
296 requireTaskReader(l);
297 const me = requireAccount(c);
298 return c.json(coordinatorJson(await projectStub(c.env, l.repo.id).coordinator(me.id)));
299});
300
301agenticApi.get("/repos/:owner/:repo/diffinery/project/coordinators", async (c) => {
302 const l = await repoOf(c);
303 requireTaskReader(l);
304 return c.json((await projectStub(c.env, l.repo.id).coordinators()).map(coordinatorSummaryJson));
305});
306
307agenticApi.put("/repos/:owner/:repo/diffinery/project/coordinator", async (c) => {
308 const l = await repoOf(c);
309 requireTaskReader(l);
310 const b = await jsonBody<CoordinatorInput>(c);
311 return c.json(coordinatorJson(await saveCoordinator(c, l, b)));
312});
313
314agenticApi.delete("/repos/:owner/:repo/diffinery/project/coordinator", async (c) => {
315 const l = await repoOf(c);
316 requireTaskReader(l);
317 await saveCoordinator(c, l, null);
318 return c.body(null, 204);
319});
320
321agenticApi.get("/repos/:owner/:repo/diffinery/project/memory", async (c) => {
322 const l = await repoOf(c);
323 requireTaskReader(l);
324 const stub = projectStub(c.env, l.repo.id);
325 const versions = await stub.memoryVersions();
326 return c.json({ ...memoryJson(versions[0] ?? null), versions: versions.map((v) => ({ version: v.n, updated_by: { login: v.by_login, type: v.by_kind === "person" ? "User" : "Bot" }, updated_at: v.at, length: v.body.length })) });
327});
328
329agenticApi.put("/repos/:owner/:repo/diffinery/project/memory", async (c) => {
330 const l = await repoOf(c);
331 requireTaskReader(l);
332 const b = await jsonBody<{ body?: unknown; restore?: unknown }>(c);
333 const restore = b.restore === undefined || b.restore === null ? undefined : Number(b.restore);
334 if (restore !== undefined && (!Number.isInteger(restore) || restore < 1)) throw new HttpError(422, "`restore` must be a version number");
335 return c.json(memoryJson(await saveMemory(c, l, b.body, restore)));
336});
337
338agenticApi.put("/repos/:owner/:repo/diffinery/project/settings", async (c) => {
339 const l = await repoOf(c);
340 requireTaskReader(l);
341 return c.json(settingsJson(await saveProjectSettings(c, l, await jsonBody<{ max_running?: unknown; instructions?: unknown; plan_first?: unknown }>(c))));
342});