| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import type { FC } from "hono/jsx"; |
| 3 | import { requireAccount, type AppContext, requireRecentSignIn } from "../auth/principal.js"; |
| 4 | import { loadRepo } from "../http/common.js"; |
| 5 | import { HttpError } from "../lib/util.js"; |
| 6 | import { SettingsNav } from "../web/account.js"; |
| 7 | import { csrfToken } from "../web/csrf.js"; |
| 8 | import { KCheckbox, KRadio, KSelect } from "../web/kumo.js"; |
| 9 | import { Form, Markdown, Time, act, post, prm, render, repoUrl, statusOf, type WebApp } from "../web/ui.js"; |
| 10 | import { isProvider, ModelKeys, PROVIDERS, type Provider } from "./keys.js"; |
| 11 | import type { TaskRow, TaskView } from "./ProjectDO.js"; |
| 12 | import { fixGoal } from "./review.js"; |
| 13 | import { MAX_ATTEMPTS, MAX_CHECK_FIXES } from "./project.js"; |
| 14 | import { formatCost, formatTokens, totalUsage, type RunUsage } from "./usage.js"; |
| 15 | import { OPENROUTER_DEFAULT_MODEL, parseRuntimeChoice, runtimeChoices, runtimeLabel, transcriptItem, type TranscriptItem } from "./runtimes.js"; |
| 16 | import { approvePlan, compareAttempts, drivableAgents, getTask, pickAttempt, setTaskOptions, type AttemptCompare, mayStop, messageTask, projectStub, requireTaskReader, retryTask, startTask, stopTask, taskNumber } from "./tasks.js"; |
| 17 | import { splitPlan } from "./projects.js"; |
| 18 | import { runTaskInTerminal, terminalForTask, terminalId } from "./terminals.js"; |
| 19 | import { usageReport, type UsageLine, type UsageReport } from "./ledger.js"; |
| 20 | |
| 21 | /** |
| 22 | * Web pages for agent tasks: Settings → Model access, and a repository's |
| 23 | * Tasks tab (list, new task, a task's live transcript with steer, stop and retry). |
| 24 | */ |
| 25 | |
| 26 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| 27 | |
| 28 | export const TASK_STATE: Record<TaskRow["state"], { label: string; tone: string }> = { |
| 29 | queued: { label: "Queued", tone: "" }, |
| 30 | running: { label: "Running", tone: "blue" }, |
| 31 | ready: { label: "Ready", tone: "green" }, |
| 32 | failed: { label: "Failed", tone: "red" }, |
| 33 | stopped: { label: "Stopped", tone: "" }, |
| 34 | }; |
| 35 | |
| 36 | const providerNames = Object.fromEntries(Object.values(PROVIDERS).map((p) => [p.id, p.name])) as Record<Provider, string>; |
| 37 | |
| 38 | /** The model field for a runtime that goes through OpenRouter (tasks and agent review share it). */ |
| 39 | export const OpenRouterModel: FC<{ value?: string | null }> = ({ value }) => ( |
| 40 | <div class="field"> |
| 41 | <label for="model"> |
| 42 | OpenRouter model <span class="faint">(through OpenRouter only)</span> |
| 43 | </label> |
| 44 | <input id="model" name="model" type="text" maxlength={200} value={value ?? ""} placeholder={`${OPENROUTER_DEFAULT_MODEL["claude-code"]} for Claude Code, ${OPENROUTER_DEFAULT_MODEL.codex} for Codex`} /> |
| 45 | <div class="hint"> |
| 46 | A model id from <a href="https://openrouter.ai/models" rel="noopener noreferrer" target="_blank">openrouter.ai/models</a>. Left empty, Claude Code uses {OPENROUTER_DEFAULT_MODEL["claude-code"]} and Codex {OPENROUTER_DEFAULT_MODEL.codex}. OpenRouter supports Claude Code fully only on Anthropic models, and Codex on OpenAI ones. |
| 47 | </div> |
| 48 | </div> |
| 49 | ); |
| 50 | |
| 51 | export const TaskBadge: FC<{ state: TaskRow["state"] }> = ({ state }) => <span class={`badge ${TASK_STATE[state].tone}`}>{TASK_STATE[state].label}</span>; |
| 52 | |
| 53 | // ---------------------------------------------------------------- usage (phase 5a) |
| 54 | |
| 55 | const RUN_KIND: Record<TaskView["runs"][number]["kind"], string> = { start: "Start", message: "Follow-up", retry: "Retry", review: "Review", coordinate: "Coordinate", plan: "Plan", build: "Build", fix: "Fix checks" }; |
| 56 | |
| 57 | export const UsageCells: FC<{ u: Omit<RunUsage, "source"> | null; subscription?: boolean }> = ({ u, subscription }) => |
| 58 | u ? ( |
| 59 | <> |
| 60 | <td class="r">{u.requests ? u.requests.toLocaleString("en-US") : "—"}</td> |
| 61 | <td class="r">{formatTokens(u.input)}</td> |
| 62 | <td class="r">{formatTokens(u.output)}</td> |
| 63 | <td class="r"> |
| 64 | {formatTokens(u.cacheRead)} / {formatTokens(u.cacheWrite)} |
| 65 | </td> |
| 66 | <td class="r">{u.costUsd === null ? "—" : `${formatCost(u.costUsd)}${subscription ? "*" : ""}`}</td> |
| 67 | </> |
| 68 | ) : ( |
| 69 | <td colspan={5} class="muted"> |
| 70 | None reported |
| 71 | </td> |
| 72 | ); |
| 73 | |
| 74 | /** A task's model usage, per run and in all: counted by Diffinery's model proxy, or as the CLI in a terminal reported it. */ |
| 75 | const UsageBox: FC<{ t: TaskView }> = ({ t }) => { |
| 76 | const total = totalUsage(t.runs.map((r) => r.usage)); |
| 77 | if (!total) return null; |
| 78 | const subscription = t.runs.some((r) => r.usage?.source === "runtime" && r.usage.costUsd !== null); |
| 79 | return ( |
| 80 | <details class="box" id="usage"> |
| 81 | <summary class="head"> |
| 82 | <h2>Usage</h2> |
| 83 | <span class="badge"> |
| 84 | {formatTokens(total.input + total.output + total.cacheRead + total.cacheWrite)} tokens{total.costUsd !== null ? ` · ${formatCost(total.costUsd)}` : ""} |
| 85 | </span> |
| 86 | </summary> |
| 87 | <div class="body"> |
| 88 | <div class="xscroll"> |
| 89 | <table class="tbl"> |
| 90 | <thead> |
| 91 | <tr> |
| 92 | <th>Run</th> |
| 93 | <th>Counted by</th> |
| 94 | <th class="r">Requests</th> |
| 95 | <th class="r">Input</th> |
| 96 | <th class="r">Output</th> |
| 97 | <th class="r">Cache read / write</th> |
| 98 | <th class="r">Cost</th> |
| 99 | </tr> |
| 100 | </thead> |
| 101 | <tbody> |
| 102 | {t.runs.map((r) => ( |
| 103 | <tr> |
| 104 | <td> |
| 105 | {r.seq} · {RUN_KIND[r.kind]} |
| 106 | {r.attempt ? ` · attempt ${r.attempt}` : ""} |
| 107 | </td> |
| 108 | <td class="muted">{r.usage ? (r.usage.source === "proxy" ? "Diffinery" : `${runtimeLabel(t.runtime)} in the terminal`) : "—"}</td> |
| 109 | <UsageCells u={r.usage} subscription={r.usage?.source === "runtime"} /> |
| 110 | </tr> |
| 111 | ))} |
| 112 | {t.runs.length > 1 ? ( |
| 113 | <tr> |
| 114 | <th>Total</th> |
| 115 | <td /> |
| 116 | <UsageCells u={total} /> |
| 117 | </tr> |
| 118 | ) : null} |
| 119 | </tbody> |
| 120 | </table> |
| 121 | </div> |
| 122 | <p class="muted small mt"> |
| 123 | {t.key_owner && t.key_owner !== t.person_id ? "The organization's key paid for this task's model requests. " : ""}Requests through a key are counted by Diffinery from the provider's answers. A run in your terminal is counted by the CLI signed in there. Cost is only ever Claude Code's own figure: Codex and custom commands report none. |
| 124 | {subscription ? " * At API prices: a subscription covers it." : ""} Your usage across repositories is in <a href="/settings/model-access#usage">Settings → Model access</a>. |
| 125 | </p> |
| 126 | </div> |
| 127 | </details> |
| 128 | ); |
| 129 | }; |
| 130 | |
| 131 | // ---------------------------------------------------------------- Settings → Model access |
| 132 | |
| 133 | const ModelAccessPage = async (c: AppContext) => { |
| 134 | const me = requireAccount(c); |
| 135 | const t = csrfToken(c); |
| 136 | const keys = new Map((await new ModelKeys(c.env).list(me.id)).map((k) => [k.provider, k])); |
| 137 | const usage = await usageReport(c.env, me.id); |
| 138 | return render( |
| 139 | c, |
| 140 | { title: "Model access" }, |
| 141 | <div class="grid left"> |
| 142 | <SettingsNav me={me} tab="model-access" db={c.env.DB} /> |
| 143 | <div> |
| 144 | <h1>Model access</h1> |
| 145 | <p class="muted"> |
| 146 | Agent tasks you start run Claude Code or Codex with your own API key: Anthropic's or OpenAI's, or OpenRouter's to run them on a model you choose there. Diffinery keeps the key encrypted, never shows it again, and never puts it in a sandbox: the agent calls the provider through Diffinery, which adds your key. See <a href="/docs/agents/tasks">Agent tasks</a>. |
| 147 | </p> |
| 148 | <div class="flash info"> |
| 149 | Anything an agent runs in its sandbox, including code from the repository and its dependencies, can use your model access while the task runs, and could be tricked into misusing it. Use a <b>dedicated key with a spending limit</b> for Diffinery, and revoke it with the provider if you suspect misuse. |
| 150 | </div> |
| 151 | {(Object.keys(PROVIDERS) as Provider[]).map((id) => { |
| 152 | const p = PROVIDERS[id]; |
| 153 | const k = keys.get(id); |
| 154 | return ( |
| 155 | <div class="box"> |
| 156 | <div class="head"> |
| 157 | <h2>{p.name}</h2> |
| 158 | {k ? <span class="badge green">Key saved</span> : <span class="badge">No key</span>} |
| 159 | </div> |
| 160 | <div class="body"> |
| 161 | {k ? ( |
| 162 | <div class="row mb"> |
| 163 | <div class="grow"> |
| 164 | <span class="mono">…{k.hint}</span> |
| 165 | <span class="muted small"> |
| 166 | {" "} |
| 167 | · added <Time at={k.createdAt} /> · updated <Time at={k.updatedAt} /> · terms accepted <Time at={k.optedInAt} /> |
| 168 | </span> |
| 169 | </div> |
| 170 | <Form action={`/settings/model-access/${id}/delete`} t={t} class="inline" confirm={`Delete your ${p.name} key? Running tasks that use it stop getting model access.`}> |
| 171 | <button class="btn sm danger">Delete</button> |
| 172 | </Form> |
| 173 | </div> |
| 174 | ) : null} |
| 175 | <Form action={`/settings/model-access/${id}`} t={t}> |
| 176 | <div class="field"> |
| 177 | <label for={`key-${id}`}>{k ? `Replace your ${p.name} API key` : `Your ${p.name} API key`}</label> |
| 178 | <input id={`key-${id}`} name="key" type="password" autocomplete="off" required placeholder={id === "anthropic" ? "sk-ant-…" : id === "openrouter" ? "sk-or-…" : "sk-…"} /> |
| 179 | <div class="hint"> |
| 180 | Create one at <a href={p.keysUrl} rel="noopener noreferrer" target="_blank">{p.keysUrl.replace("https://", "")}</a>. |
| 181 | </div> |
| 182 | </div> |
| 183 | <div class="field"> |
| 184 | <KCheckbox |
| 185 | name="accept" |
| 186 | label={ |
| 187 | <> |
| 188 | I use {p.name} through Diffinery under my own {p.name} account and its terms ({p.terms.map((x, i) => ( |
| 189 | <> |
| 190 | {i ? ", " : ""} |
| 191 | <a href={x.url} rel="noopener noreferrer" target="_blank"> |
| 192 | {x.label} |
| 193 | </a> |
| 194 | </> |
| 195 | ))} |
| 196 | ). Usage is billed to my account. Diffinery only provides the ability. |
| 197 | </> |
| 198 | } |
| 199 | /> |
| 200 | </div> |
| 201 | <button class="btn primary">{k ? "Replace key" : "Save key"}</button> |
| 202 | </Form> |
| 203 | </div> |
| 204 | </div> |
| 205 | ); |
| 206 | })} |
| 207 | <UsageReportBox report={usage} /> |
| 208 | </div> |
| 209 | </div>, |
| 210 | ); |
| 211 | }; |
| 212 | |
| 213 | const USAGE_KIND: Record<UsageLine["kind"], string> = { task: "Tasks", review: "Agent reviews", coordinator: "Coordinator" }; |
| 214 | |
| 215 | /** Phase 5a: what the person's agents used, per repository and kind, from the agent_usage ledger (ledger.ts). */ |
| 216 | const UsageReportBox: FC<{ report: UsageReport }> = ({ report }) => { |
| 217 | const total = report.lines.reduce( |
| 218 | (a, l) => ({ runs: a.runs + l.runs, requests: a.requests + l.requests, input: a.input + l.input, output: a.output + l.output, cacheRead: a.cacheRead + l.cacheRead, cacheWrite: a.cacheWrite + l.cacheWrite, costUsd: l.costUsd === null ? a.costUsd : (a.costUsd ?? 0) + l.costUsd }), |
| 219 | { runs: 0, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: null as number | null }, |
| 220 | ); |
| 221 | return ( |
| 222 | <div class="box" id="usage"> |
| 223 | <div class="head"> |
| 224 | <h2>Usage in the last {report.days} days</h2> |
| 225 | {report.lines.length ? <span class="badge">{formatTokens(total.input + total.output + total.cacheRead + total.cacheWrite)} tokens</span> : null} |
| 226 | </div> |
| 227 | <div class="body"> |
| 228 | {report.lines.length ? ( |
| 229 | <div class="xscroll"> |
| 230 | <table class="tbl"> |
| 231 | <thead> |
| 232 | <tr> |
| 233 | <th>Repository</th> |
| 234 | <th>For</th> |
| 235 | <th class="r">Runs</th> |
| 236 | <th class="r">Requests</th> |
| 237 | <th class="r">Input</th> |
| 238 | <th class="r">Output</th> |
| 239 | <th class="r">Cache read / write</th> |
| 240 | <th class="r">Cost</th> |
| 241 | </tr> |
| 242 | </thead> |
| 243 | <tbody> |
| 244 | {report.lines.map((l) => ( |
| 245 | <tr> |
| 246 | <td>{l.repository ? <a href={`/${l.repository}/tasks`}>{l.repository}</a> : <span class="muted">Unknown</span>}</td> |
| 247 | <td>{USAGE_KIND[l.kind]}</td> |
| 248 | <td class="r"> |
| 249 | {l.runs} |
| 250 | {l.reportedRuns ? <span class="muted"> ({l.reportedRuns} in a terminal)</span> : null} |
| 251 | </td> |
| 252 | <UsageCells u={l} /> |
| 253 | </tr> |
| 254 | ))} |
| 255 | {report.lines.length > 1 ? ( |
| 256 | <tr> |
| 257 | <th>Total</th> |
| 258 | <td /> |
| 259 | <td class="r">{total.runs}</td> |
| 260 | <UsageCells u={total} /> |
| 261 | </tr> |
| 262 | ) : null} |
| 263 | </tbody> |
| 264 | </table> |
| 265 | </div> |
| 266 | ) : ( |
| 267 | <p class="muted">None of your agents' runs used a model in the last {report.days} days.</p> |
| 268 | )} |
| 269 | <p class="muted small mt"> |
| 270 | Runs you pay for: your tasks and your coordinator's, and agent reviews in repositories where you turned them on. Runs an organization's key paid for are on that organization's Settings tab, for its owners. Repositories are named as they were when the runs ended. Diffinery counts requests through your keys from the provider's answers; runs in your terminal count what the CLI signed in there reported. Cost is only Claude Code's own figure, at API prices; check your provider's console for what you were billed. Also at <code>GET /api/v3/diffinery/model-usage</code>. |
| 271 | </p> |
| 272 | </div> |
| 273 | </div> |
| 274 | ); |
| 275 | }; |
| 276 | |
| 277 | // ---------------------------------------------------------------- repository Tasks tab |
| 278 | |
| 279 | async function forTasks(c: AppContext): Promise<Loaded> { |
| 280 | if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required"); |
| 281 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); |
| 282 | requireTaskReader(l); |
| 283 | return l; |
| 284 | } |
| 285 | |
| 286 | const TaskList: FC<{ l: Loaded; tasks: TaskRow[] }> = ({ l, tasks }) => { |
| 287 | const base = repoUrl(l.repo); |
| 288 | return tasks.length ? ( |
| 289 | <ul class="list"> |
| 290 | {tasks.map((t) => ( |
| 291 | <li> |
| 292 | <div class="grow"> |
| 293 | <a href={`${base}/tasks/${t.number}`}> |
| 294 | <b>{t.title}</b> |
| 295 | </a>{" "} |
| 296 | {t.kind === "review" ? <span class="badge purple">Review</span> : t.kind === "coordinator" ? <span class="badge purple">Coordinator</span> : t.coordinator_post ? <span class="badge">From a coordinator</span> : null} <TaskBadge state={t.state} /> |
| 297 | <div class="muted small"> |
| 298 | #{t.number} · {runtimeLabel(t.runtime, t)} as <a href={`/${t.agent_login}`}>{t.agent_login}</a> · started by <a href={`/${t.person_login}`}>{t.person_login}</a> <Time at={t.created_at} /> |
| 299 | {t.pr_number ? ( |
| 300 | <> |
| 301 | {" "} |
| 302 | · <a href={`${base}/pull/${t.pr_number}`}>#{t.pr_number}</a> |
| 303 | </> |
| 304 | ) : null} |
| 305 | </div> |
| 306 | </div> |
| 307 | <span class="muted small"> |
| 308 | <Time at={t.updated_at} /> |
| 309 | </span> |
| 310 | </li> |
| 311 | ))} |
| 312 | </ul> |
| 313 | ) : ( |
| 314 | <div class="empty"> |
| 315 | No agent tasks yet. Give one of your agents a goal; it works in a sandbox and opens a pull request for review. |
| 316 | </div> |
| 317 | ); |
| 318 | }; |
| 319 | |
| 320 | const ItemView: FC<{ item: TranscriptItem; base: string }> = ({ item, base }) => { |
| 321 | if (item.kind === "text") return <div class="tx-text"><Markdown text={item.body} /></div>; |
| 322 | if (item.kind === "result") |
| 323 | return ( |
| 324 | <div class="tx-result"> |
| 325 | <div class="small muted">{item.title}</div> |
| 326 | {item.body ? <Markdown text={item.body} /> : null} |
| 327 | </div> |
| 328 | ); |
| 329 | if (item.kind === "note") return <div class="tx-note muted small">{item.body}</div>; |
| 330 | if (item.kind === "tool") |
| 331 | return ( |
| 332 | <details class="tx-tool"> |
| 333 | <summary class="mono small">{item.title}</summary> |
| 334 | <pre class="tx-pre">{item.body}</pre> |
| 335 | </details> |
| 336 | ); |
| 337 | return <pre class={`tx-pre${item.kind === "error" ? " tx-err" : ""}`}>{item.body}</pre>; |
| 338 | }; |
| 339 | |
| 340 | function taskPage(c: AppContext, l: Loaded, t: TaskView, items: Array<{ run: number; item: TranscriptItem }>, truncated: boolean, canSplit = false, attempts: AttemptCompare[] = [], terminal: string | null = null) { |
| 341 | const base = repoUrl(l.repo); |
| 342 | const tk = csrfToken(c); |
| 343 | const me = requireAccount(c); |
| 344 | const review = t.kind === "review"; |
| 345 | const starter = me.id === t.person_id && t.kind === "task"; |
| 346 | const running = t.state === "running" || t.state === "queued"; |
| 347 | return render( |
| 348 | c, |
| 349 | { title: `${t.title} · Task #${t.number}`, repo: { repo: l.repo, caller: l.caller, tab: "project" }, refresh: running ? 5 : undefined }, |
| 350 | <div> |
| 351 | <div class="row mb" style="align-items:flex-start"> |
| 352 | <div class="grow"> |
| 353 | <h1 class="m0"> |
| 354 | {t.title} <span class="faint">#{t.number}</span> |
| 355 | </h1> |
| 356 | <div class="muted small mt"> |
| 357 | <TaskBadge state={t.state} /> {runtimeLabel(t.runtime, t)} as <a href={`/${t.agent_login}`}>{t.agent_login}</a>, started by <a href={`/${t.person_login}`}>{t.person_login}</a> <Time at={t.created_at} /> · branch <span class="mono">{t.branch}</span> from <span class="mono">{t.base}</span> |
| 358 | {t.pr_number ? ( |
| 359 | <> |
| 360 | {" "} |
| 361 | · pull request <a href={`${base}/pull/${t.pr_number}`}>#{t.pr_number}</a> |
| 362 | </> |
| 363 | ) : null} |
| 364 | </div> |
| 365 | </div> |
| 366 | {running && mayStop(l.caller, t) ? ( |
| 367 | <Form action={`${base}/tasks/${t.number}/stop`} t={tk} class="inline" confirm="Stop this task? The agent is stopped and its sandbox closed; work it already pushed stays."> |
| 368 | <button class="btn sm danger">Stop</button> |
| 369 | </Form> |
| 370 | ) : null} |
| 371 | {!running && starter ? ( |
| 372 | <Form action={`${base}/tasks/${t.number}/retry`} t={tk} class="inline"> |
| 373 | <button class="btn sm">Retry</button> |
| 374 | </Form> |
| 375 | ) : null} |
| 376 | {!running && terminal ? ( |
| 377 | <Form action={`${base}/tasks/${t.number}/terminal`} t={tk} class="inline"> |
| 378 | <input type="hidden" name="terminal" value={terminal} /> |
| 379 | <button class="btn sm" title={`Runs it again in your open terminal, with the ${runtimeLabel(t.runtime, t)} you're signed in to there, on your own subscription.`}> |
| 380 | Run in my terminal |
| 381 | </button> |
| 382 | </Form> |
| 383 | ) : null} |
| 384 | </div> |
| 385 | {t.coordinator_post ? ( |
| 386 | <div class="flash info"> |
| 387 | {t.person_login}'s coordinator started this task while handling <a href={`${base}/project#post-${t.coordinator_post}`}>post #{t.coordinator_post}</a>. It wrote the goal below; each run may use at most {t.token_budget ? `${t.token_budget.toLocaleString("en-US")} tokens` : "its budget"}. |
| 388 | </div> |
| 389 | ) : null} |
| 390 | <div class="box"> |
| 391 | <div class="head"> |
| 392 | <h2>Goal</h2> |
| 393 | </div> |
| 394 | <div class="body"><Markdown text={t.goal} /></div> |
| 395 | </div> |
| 396 | {t.error ? <div class="flash error">{t.error}</div> : null} |
| 397 | {t.plan && t.plan_state === "waiting" ? ( |
| 398 | <div class="box" id="plan"> |
| 399 | <div class="head"> |
| 400 | <h2>Plan</h2> |
| 401 | <span class="badge amber">Waiting for {t.person_login}</span> |
| 402 | </div> |
| 403 | <div class="body"> |
| 404 | <Markdown text={t.plan} /> |
| 405 | {starter ? ( |
| 406 | <div class="row mt" style="gap:8px;flex-wrap:wrap"> |
| 407 | <Form action={`${base}/tasks/${t.number}/plan/approve`} t={tk} class="inline"> |
| 408 | <button class="btn primary">Approve plan</button> |
| 409 | </Form> |
| 410 | {canSplit ? ( |
| 411 | <Form action={`${base}/tasks/${t.number}/plan/split`} t={tk} class="inline" confirm="Hand this plan to your coordinator to split into tasks? This task is then done with."> |
| 412 | <button class="btn">Split into tasks</button> |
| 413 | </Form> |
| 414 | ) : ( |
| 415 | <span class="muted small"> |
| 416 | <a href={`${base}/project/coordinator`}>Set up your coordinator</a> to split plans into tasks. |
| 417 | </span> |
| 418 | )} |
| 419 | <span class="muted small">Or ask for changes below.</span> |
| 420 | </div> |
| 421 | ) : null} |
| 422 | </div> |
| 423 | </div> |
| 424 | ) : t.plan ? ( |
| 425 | <details class="box"> |
| 426 | <summary class="head"> |
| 427 | <h2>Plan</h2> |
| 428 | <span class="badge">{t.plan_state === "split" ? "Split into tasks" : t.plan_state === "approved" ? "Approved" : "Being revised"}</span> |
| 429 | </summary> |
| 430 | <div class="body"> |
| 431 | <Markdown text={t.plan} /> |
| 432 | </div> |
| 433 | </details> |
| 434 | ) : t.plan_state === "planning" ? ( |
| 435 | <div class="flash info">This task plans first: its run reads the code in a read-only sandbox and writes a plan for {t.person_login} to approve, change or split into tasks.</div> |
| 436 | ) : null} |
| 437 | {attempts.length ? <Attempts t={t} attempts={attempts} base={base} tk={tk} starter={starter} /> : null} |
| 438 | {t.parent_task ? ( |
| 439 | <div class="flash info"> |
| 440 | Split from the plan of <a href={`${base}/tasks/${t.parent_task}`}>task #{t.parent_task}</a>. |
| 441 | </div> |
| 442 | ) : null} |
| 443 | {t.kind === "task" ? ( |
| 444 | <Form action={`${base}/tasks/${t.number}/options`} t={tk} class="box" id="options"> |
| 445 | <div class="head"> |
| 446 | <h2>Verify and land</h2> |
| 447 | {t.check_fixes ? <span class="badge">{t.check_fixes} of {MAX_CHECK_FIXES} fix runs used</span> : null} |
| 448 | </div> |
| 449 | <div class="body"> |
| 450 | <KCheckbox name="fix_checks" checked={!!t.fix_checks} disabled={!starter} label={<>Fix failing checks: a fix run starts when the pull request's checks fail on its head (up to {MAX_CHECK_FIXES})</>} /> |
| 451 | <KCheckbox name="land" checked={!!t.land} disabled={!starter} label={<>Land when approved: auto-merge, once a person approves its latest commit and its checks pass</>} /> |
| 452 | {starter ? ( |
| 453 | <button class="btn sm mt">Save</button> |
| 454 | ) : null} |
| 455 | </div> |
| 456 | </Form> |
| 457 | ) : null} |
| 458 | <UsageBox t={t} /> |
| 459 | {t.queued ? <div class="flash info">A message is waiting for the current run to end: {t.queued}</div> : null} |
| 460 | <div class="box"> |
| 461 | <div class="head"> |
| 462 | <h2>Transcript</h2> |
| 463 | {running ? <span class="badge blue">Live</span> : null} |
| 464 | </div> |
| 465 | <div class="body tx"> |
| 466 | {truncated ? ( |
| 467 | <p class="muted small"> |
| 468 | Showing the latest lines. The whole transcript is at <a href={`/api/v3/repos/${l.repo.owner_login}/${l.repo.name}/diffinery/tasks/${t.number}/transcript`}>the API</a>. |
| 469 | </p> |
| 470 | ) : null} |
| 471 | {items.length ? items.map((x, i) => ( |
| 472 | <> |
| 473 | {i === 0 || items[i - 1]!.run !== x.run ? <h4 class="tx-run">Run {x.run}{t.runs[x.run - 1]?.attempt ? ` · attempt ${t.runs[x.run - 1]!.attempt}` : ""}{t.runs[x.run - 1]?.message ? " · follow-up" : ""}{t.runs[x.run - 1]?.terminal_id ? ` · in ${t.person_login}'s terminal` : ""}</h4> : null} |
| 474 | <ItemView item={x.item} base={base} /> |
| 475 | </> |
| 476 | )) : <div class="empty">Starting…</div>} |
| 477 | </div> |
| 478 | </div> |
| 479 | {starter && t.attempts > 1 && (t.pick_state === "running" || t.pick_state === "waiting") ? ( |
| 480 | <p class="muted small">{t.pick_state === "running" ? `Follow-ups open once you've picked one of the ${t.attempts} attempts.` : "Pick an attempt to continue: follow-ups go to the picked attempt's branch."}</p> |
| 481 | ) : starter ? ( |
| 482 | <Form action={`${base}/tasks/${t.number}/messages`} t={tk} class="box"> |
| 483 | <div class="head"> |
| 484 | <h2>{t.plan_state === "waiting" ? "Ask for changes to the plan" : running ? "Send a follow-up" : "Continue the task"}</h2> |
| 485 | </div> |
| 486 | <div class="body"> |
| 487 | <div class="field"> |
| 488 | <textarea name="body" rows={4} required maxlength={20000} placeholder={running ? "It starts a new run on the same branch when this run ends." : "What should the agent do next? It continues on the same branch and pull request."} /> |
| 489 | </div> |
| 490 | <button class="btn primary">Send</button> |
| 491 | {!running && terminal ? ( |
| 492 | <> |
| 493 | {" "} |
| 494 | <button class="btn" formaction={`${base}/tasks/${t.number}/terminal`} name="terminal" value={terminal} title="Runs it in your open terminal, with the CLI you're signed in to there, on your own subscription."> |
| 495 | Send to my terminal |
| 496 | </button> |
| 497 | </> |
| 498 | ) : null} |
| 499 | </div> |
| 500 | </Form> |
| 501 | ) : review ? ( |
| 502 | <p class="muted small"> |
| 503 | Each run is one agent review of <a href={`${base}/pull/${t.pr_number}`}>#{t.pr_number}</a>, using {t.person_login}'s model key. To review again, use Request agent review on the pull request. |
| 504 | </p> |
| 505 | ) : t.kind === "coordinator" ? ( |
| 506 | <p class="muted small"> |
| 507 | Each run is {t.person_login}'s coordinator handling posts in the <a href={`${base}/project`}>project conversation</a>, using {t.person_login}'s model key. |
| 508 | </p> |
| 509 | ) : ( |
| 510 | <p class="muted small">Only {t.person_login}, who started this task, can steer or retry it: each run spends their own model key.</p> |
| 511 | )} |
| 512 | </div>, |
| 513 | ); |
| 514 | } |
| 515 | |
| 516 | /** Best of N: the attempts side by side, with Pick. */ |
| 517 | const Attempts: FC<{ t: TaskView; attempts: AttemptCompare[]; base: string; tk: string; starter: boolean }> = ({ t, attempts, base, tk, starter }) => { |
| 518 | const waiting = t.pick_state === "waiting"; |
| 519 | return ( |
| 520 | <div class="box" id="attempts"> |
| 521 | <div class="head"> |
| 522 | <h2>Attempts</h2> |
| 523 | {waiting ? <span class="badge amber">Pick one</span> : t.pick_state === "picked" ? <span class="badge green">Attempt {t.picked} picked</span> : t.pick_state === "running" ? <span class="badge blue">Running</span> : null} |
| 524 | </div> |
| 525 | <div class="body"> |
| 526 | <table class="tbl"> |
| 527 | <thead> |
| 528 | <tr> |
| 529 | <th>Attempt</th> |
| 530 | <th>Changes</th> |
| 531 | <th>Checks</th> |
| 532 | <th>Tokens</th> |
| 533 | <th></th> |
| 534 | </tr> |
| 535 | </thead> |
| 536 | <tbody> |
| 537 | {attempts.map((a) => ( |
| 538 | <tr id={`attempt-${a.attempt}`}> |
| 539 | <td> |
| 540 | <strong>Attempt {a.attempt}</strong> |
| 541 | <div class="muted small mono">{a.head_sha && t.pick_state !== "picked" ? <a href={`${base}/compare/${t.base}...${a.branch}`}>{a.branch}</a> : a.branch}</div> |
| 542 | </td> |
| 543 | <td>{a.head_sha ? <span>{a.files} {a.files === 1 ? "file" : "files"}, <span style="color:var(--green)">+{a.additions}</span> <span style="color:var(--red)">−{a.deletions}</span></span> : <span class="muted">{a.step === "done" ? "No changes" : a.step === "failed" ? `Failed${a.error ? `: ${a.error}` : ""}` : a.step === "stopped" ? "Stopped" : "Running"}</span>}</td> |
| 544 | <td>{a.head_sha ? <span class={`badge ${a.checks.state === "success" ? "green" : a.checks.state === "failure" ? "red" : a.checks.state === "pending" ? "blue" : ""}`}>{a.checks.state === "none" ? "No checks" : a.checks.state === "success" ? "Passing" : a.checks.state === "failure" ? `Failing: ${a.checks.failing.join(", ")}` : "Running"}</span> : null}</td> |
| 545 | <td class="muted small">{a.tokens ? `${formatTokens(a.tokens)} (${a.model_calls} ${a.model_calls === 1 ? "request" : "requests"})` : "—"}</td> |
| 546 | <td> |
| 547 | {waiting && starter && a.head_sha && a.step === "done" ? ( |
| 548 | <Form action={`${base}/tasks/${t.number}/attempts/${a.attempt}/pick`} t={tk} class="inline" confirm={`Pick attempt ${a.attempt}? Its branch becomes the task's branch and opens a pull request; the other attempts' branches are deleted.`}> |
| 549 | <button class="btn sm primary">Pick</button> |
| 550 | </Form> |
| 551 | ) : t.picked === a.attempt ? ( |
| 552 | <span class="badge green">Picked</span> |
| 553 | ) : null} |
| 554 | </td> |
| 555 | </tr> |
| 556 | ))} |
| 557 | </tbody> |
| 558 | </table> |
| 559 | {attempts.map((a) => |
| 560 | a.summary ? ( |
| 561 | <details class="mt"> |
| 562 | <summary>What attempt {a.attempt} said last</summary> |
| 563 | <Markdown text={a.summary} /> |
| 564 | </details> |
| 565 | ) : null, |
| 566 | )} |
| 567 | </div> |
| 568 | </div> |
| 569 | ); |
| 570 | }; |
| 571 | |
| 572 | async function newTaskPage(c: AppContext, l: Loaded, error?: string, v: { goal?: string; runtime?: string; agent?: string; base?: string; command?: string; model?: string; pull?: string; plan_first?: string; attempts?: string; fix_checks?: string; land?: string } = {}) { |
| 573 | const me = requireAccount(c); |
| 574 | const base = repoUrl(l.repo); |
| 575 | const tk = csrfToken(c); |
| 576 | const [agents, keys, branches, settings] = await Promise.all([drivableAgents(c, me, l), new ModelKeys(c.env).providers(me.id), l.stub.refs("refs/heads/"), projectStub(c.env, l.repo.id).projectSettings()]); |
| 577 | const runtimes = runtimeChoices(keys, providerNames); |
| 578 | const firstUsable = runtimes.find((r) => !r.disabled)?.value ?? "custom"; |
| 579 | return render( |
| 580 | c, |
| 581 | { title: "New agent task", repo: { repo: l.repo, caller: l.caller, tab: "project" }, status: error ? 422 : undefined }, |
| 582 | <div style="max-width:780px"> |
| 583 | <h1>New agent task</h1> |
| 584 | {v.pull ? ( |
| 585 | <p class="muted"> |
| 586 | The agent works in a fresh sandbox on the branch of pull request <a href={`${base}/pull/${v.pull}`}>#{v.pull}</a> and pushes its fix there as itself, under that branch's protection rules. The model calls use <a href="/settings/model-access">your own key</a>. |
| 587 | </p> |
| 588 | ) : ( |
| 589 | <p class="muted"> |
| 590 | The agent works in a fresh sandbox with this repository checked out, pushes to a new branch as itself and opens a pull request, which needs a person's approval like any agent pull request. The model calls use <a href="/settings/model-access">your own key</a>. |
| 591 | </p> |
| 592 | )} |
| 593 | {error ? <div class="flash error">{error}</div> : null} |
| 594 | {!agents.length ? ( |
| 595 | <div class="flash info"> |
| 596 | You don't have an agent with write access here yet. <a href="/settings/agents">Create an agent</a>, then give it write access to this repository (or let it inherit yours). |
| 597 | </div> |
| 598 | ) : null} |
| 599 | <Form action={`${base}/tasks`} t={tk} class="box"> |
| 600 | <div class="body"> |
| 601 | <div class="field"> |
| 602 | <label for="goal">Goal</label> |
| 603 | <textarea id="goal" name="goal" rows={8} required maxlength={20000} placeholder="Fix the Contact sales link on the pricing page so it goes to /contact, and add a test."> |
| 604 | {v.goal ?? ""} |
| 605 | </textarea> |
| 606 | <div class="hint">What you want done, as you would ask a colleague. Markdown works.</div> |
| 607 | </div> |
| 608 | <div class="field"> |
| 609 | <label>Runtime</label> |
| 610 | <KRadio name="runtime" label="Runtime" value={v.runtime ?? firstUsable} items={runtimes} /> |
| 611 | </div> |
| 612 | <OpenRouterModel value={v.model} /> |
| 613 | <div class="field"> |
| 614 | <label for="command"> |
| 615 | Command <span class="faint">(custom runtime only)</span> |
| 616 | </label> |
| 617 | <input id="command" name="command" type="text" maxlength={2000} value={v.command ?? ""} placeholder='aider --yes --message "$DIFFINERY_TASK_PROMPT"' /> |
| 618 | <div class="hint">Runs in the repository with the goal in $DIFFINERY_TASK_PROMPT, and ANTHROPIC_BASE_URL, OPENAI_BASE_URL or OPENROUTER_BASE_URL (with the matching _API_KEY) set for the providers you have keys for.</div> |
| 619 | </div> |
| 620 | <div class="row" style="align-items:flex-start"> |
| 621 | <div class="field grow"> |
| 622 | <label for="agent">Agent</label> |
| 623 | <KSelect id="agent" name="agent" block label="Agent" value={v.agent ?? agents[0]?.login} options={agents.map((a) => ({ value: a.login, label: a.name ? `${a.login} (${a.name})` : a.login }))} placeholder="Choose an agent" /> |
| 624 | </div> |
| 625 | {v.pull ? ( |
| 626 | <input type="hidden" name="pull" value={v.pull} /> |
| 627 | ) : ( |
| 628 | <div class="field grow"> |
| 629 | <label for="base">Base branch</label> |
| 630 | <KSelect id="base" name="base" block label="Base branch" value={v.base ?? l.repo.default_branch} options={branches.slice(0, 500).map((b) => ({ value: b.name.slice("refs/heads/".length), label: b.name.slice("refs/heads/".length) }))} /> |
| 631 | </div> |
| 632 | )} |
| 633 | </div> |
| 634 | <div class="field"> |
| 635 | <KCheckbox |
| 636 | name="plan_first" |
| 637 | checked={settings.planFirst === "required" || (v.plan_first ?? (settings.planFirst === "default" ? "1" : "")) === "1"} |
| 638 | disabled={settings.planFirst === "required"} |
| 639 | label={<>Plan first: the agent reads the code and writes a plan in a read-only sandbox; it builds once you approve the plan{settings.planFirst === "required" ? " (this project requires it)" : ""}</>} |
| 640 | /> |
| 641 | </div> |
| 642 | {v.pull ? null : ( |
| 643 | <div class="field" style="max-width:320px"> |
| 644 | <label for="attempts">Attempts</label> |
| 645 | <KSelect id="attempts" name="attempts" block label="Attempts" value={v.attempts ?? "1"} options={Array.from({ length: MAX_ATTEMPTS }, (_, i) => ({ value: String(i + 1), label: i === 0 ? "1 attempt" : `${i + 1} attempts, then pick the best` }))} /> |
| 646 | <p class="muted small">Each attempt runs in its own sandbox on its own branch, at the same time, and counts toward the project's limit. Each spends your model key.</p> |
| 647 | </div> |
| 648 | )} |
| 649 | <div class="field"> |
| 650 | <KCheckbox name="fix_checks" checked={(v.fix_checks ?? (v.pull ? "" : "1")) === "1"} label={<>Fix failing checks: when its pull request's checks fail, the agent gets their logs and fixes them (up to {MAX_CHECK_FIXES} runs)</>} /> |
| 651 | </div> |
| 652 | <div class="field"> |
| 653 | <KCheckbox name="land" checked={v.land === "1"} label={<>Land when approved: turn on auto-merge, so the pull request merges once a person approves it and its checks pass</>} /> |
| 654 | </div> |
| 655 | <button class="btn primary" disabled={!agents.length}> |
| 656 | Start task |
| 657 | </button> |
| 658 | </div> |
| 659 | </Form> |
| 660 | </div>, |
| 661 | ); |
| 662 | } |
| 663 | |
| 664 | export function registerTasks(web: WebApp): void { |
| 665 | web.get("/settings/model-access", (c) => ModelAccessPage(c)); |
| 666 | |
| 667 | post(web, "/settings/model-access/:provider", async (c, f) => { |
| 668 | const me = requireAccount(c); |
| 669 | const provider = prm(c, "provider"); |
| 670 | if (!isProvider(provider)) throw new HttpError(404, "not found"); |
| 671 | // Another key would send this person's later prompts and code to its account: a recent sign-in, as for organizations' keys. |
| 672 | requireRecentSignIn(c, "/settings/model-access"); |
| 673 | return act( |
| 674 | c, |
| 675 | "/settings/model-access", |
| 676 | async () => { |
| 677 | const k = await new ModelKeys(c.env).set(me, provider, f.s("key"), f.all("accept").length > 0); |
| 678 | 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 } })); |
| 679 | }, |
| 680 | `${PROVIDERS[provider].name} key saved.`, |
| 681 | ); |
| 682 | }); |
| 683 | |
| 684 | post(web, "/settings/model-access/:provider/delete", async (c) => { |
| 685 | const me = requireAccount(c); |
| 686 | const provider = prm(c, "provider"); |
| 687 | if (!isProvider(provider)) throw new HttpError(404, "not found"); |
| 688 | return act( |
| 689 | c, |
| 690 | "/settings/model-access", |
| 691 | async () => { |
| 692 | await new ModelKeys(c.env).delete(me.id, provider); |
| 693 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "model_key.delete", target: provider })); |
| 694 | }, |
| 695 | `${PROVIDERS[provider].name} key deleted.`, |
| 696 | ); |
| 697 | }); |
| 698 | |
| 699 | web.get("/:owner/:repo/tasks", async (c) => { |
| 700 | const l = await forTasks(c); |
| 701 | const tasks = await projectStub(c.env, l.repo.id).list({ limit: 100 }); |
| 702 | return render( |
| 703 | c, |
| 704 | { title: `Tasks · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "project" }, refresh: tasks.some((t) => t.state === "running" || t.state === "queued") ? 10 : undefined }, |
| 705 | <div> |
| 706 | <div class="row mb"> |
| 707 | <h1 class="grow m0">Agent tasks</h1> |
| 708 | <a class="btn" href={`${repoUrl(l.repo)}/terminals`}> |
| 709 | Terminals |
| 710 | </a> |
| 711 | {l.repo.archived ? null : ( |
| 712 | <a class="btn primary" href={`${repoUrl(l.repo)}/tasks/new`}> |
| 713 | New task |
| 714 | </a> |
| 715 | )} |
| 716 | </div> |
| 717 | <div class="box"> |
| 718 | <TaskList l={l} tasks={tasks} /> |
| 719 | </div> |
| 720 | </div>, |
| 721 | ); |
| 722 | }); |
| 723 | |
| 724 | web.get("/:owner/:repo/tasks/new", async (c) => { |
| 725 | const l = await forTasks(c); |
| 726 | // Fix it on an agent review finding: work on that pull request's branch, with the finding as the goal. |
| 727 | const pull = c.req.query("pull"); |
| 728 | // "Start a task" on a project post: the post is the goal. |
| 729 | const fromPost = c.req.query("post"); |
| 730 | if (fromPost && /^\d{1,9}$/.test(fromPost)) { |
| 731 | const [p] = await projectStub(c.env, l.repo.id).posts(Number(fromPost) - 1, 1); |
| 732 | return newTaskPage(c, l, undefined, p && p.n === Number(fromPost) && p.kind === "person" ? { goal: p.body } : {}); |
| 733 | } |
| 734 | if (!pull || !/^\d{1,9}$/.test(pull)) return newTaskPage(c, l); |
| 735 | const f = c.req.query("finding") ? await projectStub(c.env, l.repo.id).finding(c.req.query("finding")!) : null; |
| 736 | const goal = f && f.pr === Number(pull) ? fixGoal(f.pr, [f]) : `Work on pull request #${pull}: `; |
| 737 | return newTaskPage(c, l, undefined, { pull, goal }); |
| 738 | }); |
| 739 | |
| 740 | post(web, "/:owner/:repo/tasks", async (c, f) => { |
| 741 | const l = await forTasks(c); |
| 742 | const v = { goal: f.s("goal"), runtime: f.s("runtime") || "claude-code", agent: f.s("agent"), base: f.s("base"), command: f.s("command"), model: f.s("model"), pull: f.s("pull") || undefined, plan_first: f.all("plan_first").length ? "1" : "", attempts: f.s("attempts") || "1", fix_checks: f.all("fix_checks").length ? "1" : "", land: f.all("land").length ? "1" : "" }; |
| 743 | try { |
| 744 | const { runtime, provider } = parseRuntimeChoice(v.runtime); |
| 745 | const t = await startTask(c, l, { ...v, runtime, provider, model: provider ? v.model : undefined, plan_first: v.plan_first === "1", fix_checks: v.fix_checks === "1", land: v.land === "1" }); |
| 746 | return c.redirect(`${repoUrl(l.repo)}/tasks/${t.number}`, 303); |
| 747 | } catch (e) { |
| 748 | const st = statusOf(e); |
| 749 | if (st.status >= 500) throw e; |
| 750 | return newTaskPage(c, l, st.message, v); |
| 751 | } |
| 752 | }); |
| 753 | |
| 754 | web.get("/:owner/:repo/tasks/:number", async (c) => { |
| 755 | const l = await forTasks(c); |
| 756 | const n = taskNumber(c.req.param("number")); |
| 757 | const t = await getTask(c, l, n); |
| 758 | const stub = projectStub(c.env, l.repo.id); |
| 759 | // The latest lines: page backwards from the end would need an index; fetch the head and keep the tail. |
| 760 | const lines = await stub.lines(n, 0, 5000); |
| 761 | const tail = lines.slice(-1500); |
| 762 | const items = tail.flatMap((line) => transcriptItem(line).map((item) => ({ run: line.run, item }))); |
| 763 | const canSplit = t.plan_state === "waiting" && !!(await stub.coordinator(requireAccount(c).id)); |
| 764 | const attempts = t.attempts > 1 ? await compareAttempts(c, l, n) : []; |
| 765 | const terminal = await terminalForTask(c.env, requireAccount(c), l.repo, t).catch(() => null); |
| 766 | return taskPage(c, l, t, items, lines.length > tail.length, canSplit, attempts, terminal); |
| 767 | }); |
| 768 | |
| 769 | post(web, "/:owner/:repo/tasks/:number/messages", async (c, f) => { |
| 770 | const l = await forTasks(c); |
| 771 | const n = taskNumber(c.req.param("number")); |
| 772 | return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => { |
| 773 | await messageTask(c, l, n, f.s("body")); |
| 774 | }); |
| 775 | }); |
| 776 | |
| 777 | post(web, "/:owner/:repo/tasks/:number/options", async (c, f) => { |
| 778 | const l = await forTasks(c); |
| 779 | const n = taskNumber(c.req.param("number")); |
| 780 | return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => { |
| 781 | await setTaskOptions(c, l, n, { fix_checks: f.all("fix_checks").length > 0, land: f.all("land").length > 0 }); |
| 782 | }, "Saved."); |
| 783 | }); |
| 784 | |
| 785 | post(web, "/:owner/:repo/tasks/:number/attempts/:attempt/pick", async (c) => { |
| 786 | const l = await forTasks(c); |
| 787 | const n = taskNumber(c.req.param("number")); |
| 788 | const k = Number(prm(c, "attempt")); |
| 789 | return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => { |
| 790 | await pickAttempt(c, l, n, k); |
| 791 | }, `Attempt ${k} picked.`); |
| 792 | }); |
| 793 | |
| 794 | post(web, "/:owner/:repo/tasks/:number/plan/approve", async (c) => { |
| 795 | const l = await forTasks(c); |
| 796 | const n = taskNumber(c.req.param("number")); |
| 797 | return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => { |
| 798 | await approvePlan(c, l, n); |
| 799 | }, "Plan approved: building it."); |
| 800 | }); |
| 801 | |
| 802 | post(web, "/:owner/:repo/tasks/:number/plan/split", async (c) => { |
| 803 | const l = await forTasks(c); |
| 804 | const n = taskNumber(c.req.param("number")); |
| 805 | return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => { |
| 806 | const out = await splitPlan(c, l, n); |
| 807 | return `${repoUrl(l.repo)}/project#post-${out.post}`; |
| 808 | }, "Your coordinator is splitting the plan into tasks."); |
| 809 | }); |
| 810 | |
| 811 | post(web, "/:owner/:repo/tasks/:number/retry", async (c) => { |
| 812 | const l = await forTasks(c); |
| 813 | const n = taskNumber(c.req.param("number")); |
| 814 | return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => { |
| 815 | await retryTask(c, l, n); |
| 816 | }, "Retrying."); |
| 817 | }); |
| 818 | |
| 819 | // Phase 4b: only the person's own click here runs a task in their terminal (with the follow-up, if they wrote one). |
| 820 | post(web, "/:owner/:repo/tasks/:number/terminal", async (c, f) => { |
| 821 | const l = await forTasks(c); |
| 822 | const n = taskNumber(c.req.param("number")); |
| 823 | return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => { |
| 824 | await runTaskInTerminal(c, l, n, terminalId(f.s("terminal")), f.s("body")); |
| 825 | }, "Running in your terminal."); |
| 826 | }); |
| 827 | |
| 828 | post(web, "/:owner/:repo/tasks/:number/stop", async (c) => { |
| 829 | const l = await forTasks(c); |
| 830 | const n = taskNumber(c.req.param("number")); |
| 831 | return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => { |
| 832 | await stopTask(c, l, n); |
| 833 | }, "Task stopped."); |
| 834 | }); |
| 835 | } |