| 1 | import { requireAccount, type AppContext } from "../auth/principal.js"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import type { loadRepo } from "../http/common.js"; |
| 4 | import type { Account, Directory } from "../lib/directory.js"; |
| 5 | import { HttpError } from "../lib/util.js"; |
| 6 | import { atLeast, type Caller } from "../repo/types.js"; |
| 7 | import { mayDriveAgent, taskCaller, TaskAccessError } from "./access.js"; |
| 8 | import { ModelKeys, PROVIDERS, type Provider } from "./keys.js"; |
| 9 | import type { TaskRow, TaskView } from "./ProjectDO.js"; |
| 10 | import { 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 | |
| 22 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| 23 | |
| 24 | export 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. */ |
| 27 | export 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. */ |
| 32 | export 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 | |
| 41 | export 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 | |
| 47 | export 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. */ |
| 55 | async 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 | |
| 63 | async 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 | |
| 72 | export interface StartInput { |
| 73 | goal?: unknown; |
| 74 | runtime?: unknown; |
| 75 | agent?: unknown; |
| 76 | base?: unknown; |
| 77 | command?: unknown; |
| 78 | } |
| 79 | |
| 80 | export 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. */ |
| 104 | async 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 | |
| 115 | export 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 | |
| 125 | export 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. */ |
| 133 | export const mayStop = (caller: Caller, t: TaskRow) => caller.id === t.person_id || caller.id === t.agent_id || atLeast(caller.role, "admin"); |
| 134 | |
| 135 | export 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. */ |
| 144 | export 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 | |
| 158 | export 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 | |
| 180 | export const lineJson = (l: TranscriptLine) => ({ seq: l.seq, run: l.run, at: l.at, stream: l.stream, text: l.text }); |
| 181 | |
| 182 | export 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 | */ |
| 199 | export 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 | |
| 223 | export 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 | |
| 236 | export 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 | } |