| 1 | import { Hono } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import { requireAccount, requireRecentSignIn, type AppContext, type AppVars } from "../auth/principal.js"; |
| 4 | import { loadRepo } from "../http/common.js"; |
| 5 | import { HttpError } from "../lib/util.js"; |
| 6 | import { isProvider, ModelKeys, type ModelKeyInfo } from "./keys.js"; |
| 7 | import { approvePlan, attemptJson, compareAttempts, getTask, pickAttempt, setTaskOptions, lineJson, messageTask, myTasks, projectStub, requirePersonSession, requireTaskReader, retryTask, startTask, stopTask, taskJson, taskNumber, type StartInput } from "./tasks.js"; |
| 8 | import { askForReview, findingJson, reviewSettings, reviewSettingsJson, saveReviewSettings, type SettingsInput } from "./reviews.js"; |
| 9 | import { gatherToday, todayJson } from "./today.js"; |
| 10 | import { orgUsageReport, orgUsageReportJson, usageDays, usageReport, usageReportJson } from "./ledger.js"; |
| 11 | import { orgPolicy, orgSpend, parsePolicy, policyJson, savePolicy, spendMonth, type PolicyInput } from "./org-policy.js"; |
| 12 | import { boardJson, coordinatorJson, coordinatorSummaryJson, handlePost, splitPlan, loadBoard, memoryJson, postJson, postToProject, saveCoordinator, saveMemory, saveProjectSettings, settingsJson, type CoordinatorInput } from "./projects.js"; |
| 13 | |
| 14 | async 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). */ |
| 23 | export const agenticApi = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 24 | |
| 25 | /** The caller's token restriction to repositories, if any. */ |
| 26 | export const tokenRepoIds = (c: AppContext): string[] | null => { |
| 27 | const p = c.get("principal"); |
| 28 | return p.type === "account" ? p.repoIds : null; |
| 29 | }; |
| 30 | |
| 31 | agenticApi.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 | |
| 39 | const 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 | |
| 41 | agenticApi.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). */ |
| 44 | agenticApi.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 | |
| 52 | agenticApi.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 | |
| 66 | agenticApi.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 | */ |
| 81 | async 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 | |
| 93 | agenticApi.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 | |
| 98 | agenticApi.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 | |
| 104 | agenticApi.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 | |
| 109 | agenticApi.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 | |
| 120 | agenticApi.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. */ |
| 130 | agenticApi.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 | |
| 141 | const base = (c: AppContext) => c.env.PUBLIC_URL.replace(/\/$/, ""); |
| 142 | const full = (l: { repo: { owner_login: string; name: string } }) => `${l.repo.owner_login}/${l.repo.name}`; |
| 143 | const repoOf = (c: AppContext, min: "read" | "write" | "admin" = "read") => loadRepo(c, c.req.param("owner")!, c.req.param("repo")!, min); |
| 144 | |
| 145 | agenticApi.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 | |
| 153 | agenticApi.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 | |
| 160 | agenticApi.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 | |
| 165 | agenticApi.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 | |
| 170 | agenticApi.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 | |
| 178 | agenticApi.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 | |
| 185 | agenticApi.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 | |
| 191 | agenticApi.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 | |
| 196 | agenticApi.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 | |
| 203 | agenticApi.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 | |
| 209 | agenticApi.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 | |
| 220 | agenticApi.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 | |
| 230 | agenticApi.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 | |
| 240 | agenticApi.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 | |
| 246 | agenticApi.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 | |
| 253 | agenticApi.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 | |
| 262 | agenticApi.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 | |
| 269 | agenticApi.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 | |
| 276 | agenticApi.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 | |
| 281 | agenticApi.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 | |
| 286 | agenticApi.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 | |
| 294 | agenticApi.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 | |
| 301 | agenticApi.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 | |
| 307 | agenticApi.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 | |
| 314 | agenticApi.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 | |
| 321 | agenticApi.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 | |
| 329 | agenticApi.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 | |
| 338 | agenticApi.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 | }); |