Public: anyone can read this; the crew can change it.
The inbox's decision forms leave asks.ts for src/web/studio/decisions.ts #6454
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6311-the-inbox-s-decision-forms-leave-asks-ts → main 4 commits fork
7 files
changed +365
−355
docs/STUDIO.md +2 −1
| @@ -333,7 +333,8 @@ issue, which is also what the board's line above its regions offers), and, for a | ||
| 333 | 333 | forge's health (`needs` in `/api/v1/health`). What counts as an ask is |
| 334 | 334 | defined once, in `src/web/studio/asks.ts`: the header's **Asks** count, the |
| 335 | 335 | page, the dashboard's _Needs you_ link and the phone notifier all read it, |
| 336 | so the numbers agree. It is server-rendered and every button is a form post, | |
| 336 | so the numbers agree. Each card's words, forms and buttons are drawn in | |
| 337 | `src/web/studio/decisions.ts`. It is server-rendered and every button is a form post, | |
| 337 | 338 | so it works without JavaScript. |
| 338 | 339 | |
| 339 | 340 | ## What you asked for |
src/web/studio/asks.ts +7 −342
| @@ -2,15 +2,14 @@ | ||
| 2 | 2 | * What asks a person, and what they can do about it: a *decision* is one |
| 3 | 3 | * card — a pull the crew stopped on, a green pull waiting for a look, or a |
| 4 | 4 | * repository parked at its ceiling — read from the same tables the rest of |
| 5 | * the forge reads, with the same access the owner has. This module owns the | |
| 6 | * card, the row and the panel of them; every button here posts | |
| 7 | * to the route a person could have posted to by hand (`actions.ts`). | |
| 5 | * the forge reads, with the same access the owner has. This module owns | |
| 6 | * which asks there are, the row and the panel of them; the card's words, | |
| 7 | * forms and buttons are `decisions.ts`. | |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | 11 | import { forgeVitals, needLine } from "../../actions/health.ts"; |
| 12 | 12 | import type { Degraded } from "../../kernel/vitals.ts"; |
| 13 | import { previewEnv } from "../../apps/data.ts"; | |
| 14 | 13 | import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts"; |
| 15 | 14 | import { crewForRepos } from "../../core/agents.ts"; |
| 16 | 15 | import { appsForRepos } from "../../core/apps.ts"; |
| @@ -31,7 +30,6 @@ import { | ||
| 31 | 30 | type Issue, |
| 32 | 31 | } from "../../core/issues.ts"; |
| 33 | 32 | import { mayView } from "../../core/sealed.ts"; |
| 34 | import { looksForApps } from "../../core/looks.ts"; | |
| 35 | 33 | import { closesFromBody, getPull, joinMany, type Pull } from "../../core/pulls.ts"; |
| 36 | 34 | import { |
| 37 | 35 | canRead, |
| @@ -56,18 +54,11 @@ import { | ||
| 56 | 54 | } from "../../crew/autonomy.ts"; |
| 57 | 55 | import { breakerParked, WAITING_NOTES } from "../../crew/breaker.ts"; |
| 58 | 56 | import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts"; |
| 59 | import { handoverLines, handoversOf, type Handover } from "../../crew/guard.ts"; | |
| 57 | import { handoversOf, type Handover } from "../../crew/guard.ts"; | |
| 60 | 58 | import { conflictsOf, isStalePull, mergesAgainSince, staleGreen } from "../../crew/stale_asks.ts"; |
| 61 | 59 | import { reviewWaitsFor, type ReviewWait } from "../../crew/review.ts"; |
| 62 | 60 | import type { Ctx } from "../../http/context.ts"; |
| 63 | import { repoBase } from "../chrome.ts"; | |
| 64 | import { html, type Html } from "../html.ts"; | |
| 65 | import { btn, row } from "../ui.ts"; | |
| 66 | import { lookUrl } from "../../core/looks.ts"; | |
| 67 | import { thumbLook } from "../pages/looks.ts"; | |
| 68 | import { changeHref } from "./nouns.ts"; | |
| 69 | 61 | import { NO_LONGER_ASKS } from "../../actions/asks_words.ts"; |
| 70 | import { testsClause } from "./words.ts"; | |
| 71 | 62 | |
| 72 | 63 | /** Repos narrowed to those with an app record, with the records — the one |
| 73 | 64 | * join `appsOf` and `writableAppsOf` share. */ |
| @@ -110,9 +101,6 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) { | ||
| 110 | 101 | |
| 111 | 102 | export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "waiting" | "assigned" | "proposal"; |
| 112 | 103 | |
| 113 | /** Dollars as a person reads them: cents when small, whole when not. */ | |
| 114 | const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0)); | |
| 115 | ||
| 116 | 104 | export interface Decision { |
| 117 | 105 | readonly kind: DecisionKind; |
| 118 | 106 | /** Stable across renders — the kind and what it is about — so a form can |
| @@ -788,7 +776,7 @@ export function askRows< | ||
| 788 | 776 | })); |
| 789 | 777 | } |
| 790 | 778 | |
| 791 | type Scored = AskCandidate & { | |
| 779 | export type Scored = AskCandidate & { | |
| 792 | 780 | readonly consequence: Consequence; |
| 793 | 781 | readonly waiting: WaitingFor; |
| 794 | 782 | }; |
| @@ -805,7 +793,7 @@ function waitingOf(db: Database, user: User | undefined, a: AskCandidate): Waiti | ||
| 805 | 793 | } |
| 806 | 794 | |
| 807 | 795 | /** Every candidate for the viewer, scored, dismissed or not. */ |
| 808 | function scoredFor( | |
| 796 | export function scoredFor( | |
| 809 | 797 | db: Database, |
| 810 | 798 | user: User | undefined, |
| 811 | 799 | repos: readonly Repo[], |
| @@ -978,7 +966,7 @@ function lineOf(c: AskCandidate): string { | ||
| 978 | 966 | } |
| 979 | 967 | |
| 980 | 968 | /** Why an agent's change waits for a person's review, in a clause. */ |
| 981 | const reviewWhy = (wait: ReviewWait | undefined): string => | |
| 969 | export const reviewWhy = (wait: ReviewWait | undefined): string => | |
| 982 | 970 | wait?.why === "rounds" |
| 983 | 971 | ? `the reviewer asked for changes three times, since ${wait.since.slice(0, 10)}` |
| 984 | 972 | : wait?.why === "lapsed" && wait.lateCheckMinutes !== undefined |
| @@ -1100,316 +1088,6 @@ export function askLinesFor( | ||
| 1100 | 1088 | }); |
| 1101 | 1089 | } |
| 1102 | 1090 | |
| 1103 | /** | |
| 1104 | * The decisions waiting on the viewer, worded and pictured. A change's own | |
| 1105 | * page passes `evenDismissed`: setting an ask aside quiets the lists, never | |
| 1106 | * the decision on the thing itself. | |
| 1107 | */ | |
| 1108 | export function decisionsFor( | |
| 1109 | ctx: Ctx, | |
| 1110 | allRepos: readonly Repo[], | |
| 1111 | options: { evenDismissed?: boolean } = {}, | |
| 1112 | ): Decision[] { | |
| 1113 | const db = ctx.app.db; | |
| 1114 | const candidates = options.evenDismissed | |
| 1115 | ? scoredFor(db, ctx.auth.user, allRepos) | |
| 1116 | : waitingOn(db, ctx.auth.user, allRepos); | |
| 1117 | // The pictures, in one read: a green pull's row leads with its preview. | |
| 1118 | const repoIds = [...new Set(candidates.map((c) => c.repo.id))]; | |
| 1119 | const records = appsForRepos(db, repoIds); | |
| 1120 | const looks = looksForApps( | |
| 1121 | db, | |
| 1122 | [...records.values()].map((r) => r.id), | |
| 1123 | ); | |
| 1124 | const pictureOf = (repo: Repo, pull: Pull): string | undefined => { | |
| 1125 | const record = records.get(repo.id); | |
| 1126 | if (record === undefined) return undefined; | |
| 1127 | const env = previewEnv(pull.number); | |
| 1128 | const look = thumbLook( | |
| 1129 | ctx, | |
| 1130 | repo, | |
| 1131 | record, | |
| 1132 | looks.filter((l) => l.appId === record.id && l.env === env), | |
| 1133 | ); | |
| 1134 | return ( | |
| 1135 | look && lookUrl(repoBase(repo), look) | |
| 1136 | ); | |
| 1137 | }; | |
| 1138 | const worded = (c: Scored): Omit<Decision, "consequence" | "waiting"> => { | |
| 1139 | if (c.kind === "assigned") { | |
| 1140 | const thing = c.pull ?? c.issue!; | |
| 1141 | return { | |
| 1142 | kind: "assigned", | |
| 1143 | id: `assigned:${thing.id}`, | |
| 1144 | repo: c.repo, | |
| 1145 | ...(c.pull === undefined ? { issue: c.issue } : { pull: c.pull }), | |
| 1146 | who: c.who, | |
| 1147 | says: `"${thing.title}" is assigned to you.`, | |
| 1148 | short: `assigned to you · updated ${thing.updatedAt.slice(0, 10)}`, | |
| 1149 | }; | |
| 1150 | } | |
| 1151 | if (c.kind === "proposal" && c.proposal) | |
| 1152 | return { | |
| 1153 | kind: "proposal", | |
| 1154 | id: `proposal:${c.proposal.id}`, | |
| 1155 | repo: c.repo, | |
| 1156 | proposal: c.proposal, | |
| 1157 | who: c.who, | |
| 1158 | says: `${c.who} proposes "${c.proposal.title}" (${issueTitles(c.proposal).length} issues). Approve opens them, Redirect hands your words to the planner, Later puts it aside.`, | |
| 1159 | short: `proposes "${c.proposal.title}"`, | |
| 1160 | }; | |
| 1161 | if (c.kind === "parked" && c.issue) | |
| 1162 | return { | |
| 1163 | kind: "parked", | |
| 1164 | id: `parked:${c.issue.id}`, | |
| 1165 | repo: c.repo, | |
| 1166 | issue: c.issue, | |
| 1167 | who: c.who, | |
| 1168 | says: `Three runs on #${c.issue.number} failed or were declined within an hour, so the crew parked it. Say what to try and it starts again.`, | |
| 1169 | short: `#${c.issue.number} parked after three failed runs`, | |
| 1170 | }; | |
| 1171 | if (c.kind === "declined" && c.issue && c.decline) | |
| 1172 | return { | |
| 1173 | kind: "declined", | |
| 1174 | id: `declined:${c.decline.runId}`, | |
| 1175 | repo: c.repo, | |
| 1176 | issue: c.issue, | |
| 1177 | who: c.who, | |
| 1178 | says: `The planner declined the direction: \u201c${c.decline.words}\u201d. It plans nothing more until you answer on #${c.issue.number}.`, | |
| 1179 | short: `the planner declined the direction: \u201c${c.decline.words}\u201d`, | |
| 1180 | }; | |
| 1181 | if (c.kind === "handover" && c.pull && c.handover) | |
| 1182 | return { | |
| 1183 | kind: "handover", | |
| 1184 | id: `handover:${c.pull.id}`, | |
| 1185 | repo: c.repo, | |
| 1186 | pull: c.pull, | |
| 1187 | who: c.who, | |
| 1188 | says: `The reviewer asked for changes three times with no approval between, so the crew stopped. ${handoverLines(c.handover).join(" ")} Say what to try (or approve it on its page), or close it.`, | |
| 1189 | short: `"${c.pull.title}" had three reviews asking for changes`, | |
| 1190 | why: c.why, | |
| 1191 | recommendation: c.recommendation, | |
| 1192 | giveBack: c.builder, | |
| 1193 | }; | |
| 1194 | if (c.kind === "waiting" && c.pull) | |
| 1195 | return { | |
| 1196 | kind: "waiting", | |
| 1197 | id: `waiting:${c.pull.id}`, | |
| 1198 | repo: c.repo, | |
| 1199 | pull: c.pull, | |
| 1200 | who: c.who, | |
| 1201 | says: `"${c.pull.title}" is ${c.why ?? "waiting"}, and no person has acted on it. Ship it, say what to change, or close it.`, | |
| 1202 | short: c.why ?? "waiting for a person", | |
| 1203 | why: c.why, | |
| 1204 | recommendation: c.recommendation, | |
| 1205 | }; | |
| 1206 | if (c.kind === "stopped" && c.pull) { | |
| 1207 | const tries = c.tries ?? 0; | |
| 1208 | const stopped = c.stoppedByDecider ?? false; | |
| 1209 | return { | |
| 1210 | kind: "stopped", | |
| 1211 | id: `stopped:${c.pull.id}`, | |
| 1212 | repo: c.repo, | |
| 1213 | pull: c.pull, | |
| 1214 | who: c.who, | |
| 1215 | says: stopped | |
| 1216 | ? `${c.who}'s check failed and the crew's decider stopped rather than spend another run. Say what to try, or close it.` | |
| 1217 | : `${c.who} tried ${tries + 1} times and the check still fails. The crew stopped here. Say what to try, or close it.`, | |
| 1218 | short: stopped | |
| 1219 | ? `the check failed; the decider stopped for you` | |
| 1220 | : `${c.who} tried ${tries + 1} times; the check still fails`, | |
| 1221 | }; | |
| 1222 | } | |
| 1223 | if (c.kind === "review" && c.pull) | |
| 1224 | return { | |
| 1225 | kind: "review", | |
| 1226 | id: `review:${c.pull.id}`, | |
| 1227 | repo: c.repo, | |
| 1228 | pull: c.pull, | |
| 1229 | who: c.who, | |
| 1230 | says: `${c.who}'s "${c.pull.title}" is green and waits for a review: ${reviewWhy(c.reviewWait)}. An agent's change never ships unreviewed; ship it, or say what is off.`, | |
| 1231 | short: `green; waits for a review — ${reviewWhy(c.reviewWait)}`, | |
| 1232 | }; | |
| 1233 | if (c.kind === "look" && c.pull) { | |
| 1234 | const picture = pictureOf(c.repo, c.pull); | |
| 1235 | const clause = testsClause(c.check ?? null); | |
| 1236 | // The crew's own words say who finished it; a person's own change | |
| 1237 | // gets the wording that is true of it — nobody "finished" it for | |
| 1238 | // them, it is simply green and waiting. | |
| 1239 | return { | |
| 1240 | kind: "look", | |
| 1241 | id: `look:${c.pull.id}`, | |
| 1242 | repo: c.repo, | |
| 1243 | pull: c.pull, | |
| 1244 | who: c.who, | |
| 1245 | says: c.authorIsAgent | |
| 1246 | ? `${c.who} finished "${c.pull.title}" and the check is green${clause === "" ? "" : ` (${clause})`}. Look at the preview, then ship it or say what is off.` | |
| 1247 | : `"${c.pull.title}" is green — ${clause === "" ? "" : `${clause}, `}ready for a look. Ship it, or say what is off.`, | |
| 1248 | short: c.authorIsAgent | |
| 1249 | ? `${c.who} finished it; the check is green${clause === "" ? "" : ` (${clause})`}` | |
| 1250 | : `green — ${clause === "" ? "" : `${clause}, `}ready for a look`, | |
| 1251 | ...(picture === undefined ? {} : { picture }), | |
| 1252 | }; | |
| 1253 | } | |
| 1254 | return { | |
| 1255 | kind: "paused", | |
| 1256 | id: `paused:${c.repo.id}`, | |
| 1257 | repo: c.repo, | |
| 1258 | who: "the crew", | |
| 1259 | says: `${c.repo.name} reached its $${dollars(c.repo.autonomyCeilingUsd)} for today. The crew picks nothing more until 00:00 UTC. Raise the ceiling in settings to go on now.`, | |
| 1260 | short: `at its $${dollars(c.repo.autonomyCeilingUsd)} ceiling until 00:00 UTC`, | |
| 1261 | }; | |
| 1262 | }; | |
| 1263 | const previewOf = (c: Scored): string | undefined => | |
| 1264 | c.pull === undefined || !records.has(c.repo.id) | |
| 1265 | ? undefined | |
| 1266 | : `${repoBase(c.repo)}/app/pull-${c.pull.number}/open`; | |
| 1267 | return candidates.map((c) => ({ | |
| 1268 | ...worded(c), | |
| 1269 | preview: previewOf(c), | |
| 1270 | consequence: c.consequence, | |
| 1271 | waiting: c.waiting, | |
| 1272 | })); | |
| 1273 | } | |
| 1274 | /** The id of the form a row's own button submits (see `decisionForm`). */ | |
| 1275 | const askFormId = (d: Decision): string => `ask-${d.id}`; | |
| 1276 | ||
| 1277 | /** | |
| 1278 | * The form behind a row's landing button — its own post, exactly as it was. | |
| 1279 | * It sits after the panel's form, because a form inside a form is not | |
| 1280 | * HTML; the button reaches it by id. | |
| 1281 | */ | |
| 1282 | export function decisionForm(d: Decision, back?: string): Html | null { | |
| 1283 | const pull = d.pull; | |
| 1284 | const own = html`id="${askFormId(d)}" method="post" hidden`; | |
| 1285 | if (d.kind === "parked") | |
| 1286 | return html`<form ${own} action="/asks/decide"> | |
| 1287 | <input type="hidden" name="ask" value="${d.id}" /> | |
| 1288 | </form>`; | |
| 1289 | if (d.kind === "proposal" && d.proposal) return proposalForms(d, back); | |
| 1290 | if (pull === undefined) return null; | |
| 1291 | const base = repoBase(d.repo); | |
| 1292 | const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`; | |
| 1293 | if (d.kind === "handover" && d.giveBack !== undefined) | |
| 1294 | return html`<form ${own} action="${base}/pulls/${pull.number}/state"> | |
| 1295 | <input type="hidden" name="state" value="closed" />${where} | |
| 1296 | </form> | |
| 1297 | <form id="${askFormId(d)}-back" method="post" hidden action="/asks/give-back"> | |
| 1298 | <input type="hidden" name="ask" value="${d.id}" />${where} | |
| 1299 | </form>`; | |
| 1300 | if (d.kind === "stopped" || d.kind === "handover") | |
| 1301 | return html`<form ${own} action="${base}/pulls/${pull.number}/state"> | |
| 1302 | <input type="hidden" name="state" value="closed" />${where} | |
| 1303 | </form>`; | |
| 1304 | if (d.kind === "look" || d.kind === "review") | |
| 1305 | return html`<form ${own} action="${base}/pulls/${pull.number}/ship"> | |
| 1306 | <input type="hidden" name="pull" value="${pull.number}" />${where} | |
| 1307 | </form>`; | |
| 1308 | if (d.kind === "waiting") | |
| 1309 | return html`<form ${own} action="${base}/pulls/${pull.number}/ship"> | |
| 1310 | <input type="hidden" name="pull" value="${pull.number}" />${where} | |
| 1311 | </form> | |
| 1312 | <form id="${askFormId(d)}-close" method="post" hidden action="${base}/pulls/${pull.number}/state"> | |
| 1313 | <input type="hidden" name="state" value="closed" />${where} | |
| 1314 | </form>`; | |
| 1315 | return null; | |
| 1316 | } | |
| 1317 | ||
| 1318 | /** | |
| 1319 | * The actions on one ask, on one line: a button for what lands (submitting | |
| 1320 | * the row's own form, `decisionForm`), links for what needs a look or a | |
| 1321 | * word. Saying something to the crew happens on the change's own page, | |
| 1322 | * where the conversation is — not in a box in a list. | |
| 1323 | */ | |
| 1324 | export function decisionActions(ctx: Ctx, d: Decision): Html { | |
| 1325 | const { primary, others } = decisionButtons(ctx, d); | |
| 1326 | return html`${primary} ${others}`; | |
| 1327 | } | |
| 1328 | ||
| 1329 | /** An ask's actions in two parts: the one that decides, and the rest. */ | |
| 1330 | export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others: Html[] } { | |
| 1331 | const base = repoBase(d.repo); | |
| 1332 | const pull = d.pull; | |
| 1333 | const preview = d.preview; | |
| 1334 | if ((d.kind === "stopped" || d.kind === "handover") && pull) | |
| 1335 | return { | |
| 1336 | primary: btn("Tell them", { | |
| 1337 | href: `${changeHref(base, pull.number)}#comment-body`, | |
| 1338 | size: "sm", | |
| 1339 | variant: "primary", | |
| 1340 | }), | |
| 1341 | others: [ | |
| 1342 | ...(d.kind === "handover" && d.giveBack !== undefined | |
| 1343 | ? [btn("Give back to the crew", { size: "sm", form: `${askFormId(d)}-back` })] | |
| 1344 | : []), | |
| 1345 | btn("Close", { size: "sm", form: askFormId(d) }), | |
| 1346 | btn("See", { href: changeHref(base, pull.number), size: "sm" }), | |
| 1347 | ], | |
| 1348 | }; | |
| 1349 | if ((d.kind === "look" || d.kind === "review") && pull) | |
| 1350 | return { | |
| 1351 | primary: btn("Ship", { size: "sm", form: askFormId(d), variant: "primary" }), | |
| 1352 | others: [ | |
| 1353 | ...(preview === undefined ? [] : [btn("Preview", { href: preview, size: "sm" })]), | |
| 1354 | btn("Not like this", { | |
| 1355 | href: `${changeHref(base, pull.number)}#comment-body`, | |
| 1356 | size: "sm", | |
| 1357 | }), | |
| 1358 | ], | |
| 1359 | }; | |
| 1360 | if (d.kind === "waiting" && pull) | |
| 1361 | return { | |
| 1362 | primary: btn("Ship", { size: "sm", form: askFormId(d), variant: "primary" }), | |
| 1363 | others: [ | |
| 1364 | btn("Say what to change", { | |
| 1365 | href: `${changeHref(base, pull.number)}#comment-body`, | |
| 1366 | size: "sm", | |
| 1367 | }), | |
| 1368 | btn("Close", { size: "sm", form: `${askFormId(d)}-close` }), | |
| 1369 | ], | |
| 1370 | }; | |
| 1371 | if (d.kind === "assigned" && (d.pull ?? d.issue)) | |
| 1372 | return { | |
| 1373 | primary: btn("Open", { | |
| 1374 | href: pull ? changeHref(base, pull.number) : `${base}/issues/${d.issue!.number}`, | |
| 1375 | size: "sm", | |
| 1376 | variant: "primary", | |
| 1377 | }), | |
| 1378 | others: [], | |
| 1379 | }; | |
| 1380 | if (d.kind === "proposal" && d.proposal) | |
| 1381 | return { | |
| 1382 | primary: btn("Approve", { size: "sm", form: askFormId(d), variant: "primary" }), | |
| 1383 | others: [btn("Later", { size: "sm", form: `${askFormId(d)}-later` })], | |
| 1384 | }; | |
| 1385 | if (d.kind === "declined" && d.issue) | |
| 1386 | return { | |
| 1387 | primary: btn("Answer", { | |
| 1388 | href: `${base}/issues/${d.issue.number}#comment-body`, | |
| 1389 | size: "sm", | |
| 1390 | variant: "primary", | |
| 1391 | }), | |
| 1392 | others: [], | |
| 1393 | }; | |
| 1394 | if (d.kind === "parked" && d.issue) | |
| 1395 | return { | |
| 1396 | primary: btn("Tell them", { | |
| 1397 | href: `${base}/issues/${d.issue.number}#comment-body`, | |
| 1398 | size: "sm", | |
| 1399 | variant: "primary", | |
| 1400 | }), | |
| 1401 | // Only where a decider is there to answer. | |
| 1402 | others: | |
| 1403 | ctx.app.config.decider === undefined | |
| 1404 | ? [] | |
| 1405 | : [btn("Let the decider", { size: "sm", form: askFormId(d) })], | |
| 1406 | }; | |
| 1407 | return { | |
| 1408 | primary: btn("Settings", { href: `${base}/settings/autonomy`, size: "sm" }), | |
| 1409 | others: [], | |
| 1410 | }; | |
| 1411 | } | |
| 1412 | ||
| 1413 | 1091 | /** |
| 1414 | 1092 | * What the forge itself needs, for a site admin: each sentence of the |
| 1415 | 1093 | * health answer (`/api/v1/health`) as one ask. Anyone else gets none — the |
| @@ -1543,16 +1221,3 @@ export function askTargetsFor( | ||
| 1543 | 1221 | } |
| 1544 | 1222 | return { issueIds: [...new Set(issueIds)], refused }; |
| 1545 | 1223 | } |
| 1546 | ||
| 1547 | /** | |
| 1548 | * The forms behind a proposal card: Approve, Later and Redirect, each one | |
| 1549 | * POST to `/asks/proposal/:id/<verb>` that lands back on the page it came | |
| 1550 | * from. They sit apart from the card's own form, as forms do not nest. | |
| 1551 | */ | |
| 1552 | function proposalForms(d: Decision, back?: string): Html { | |
| 1553 | const id = askFormId(d); | |
| 1554 | const post = (verb: string) => `/asks/proposal/${d.proposal!.id}/${verb}`; | |
| 1555 | const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`; | |
| 1556 | return html`<form id="${id}" method="post" action="${post("approve")}" hidden>${where}</form> | |
| 1557 | <form id="${id}-later" method="post" action="${post("later")}" hidden>${where}</form>`; | |
| 1558 | } |
src/web/studio/asks_page.ts +1 −3
| @@ -50,9 +50,6 @@ import { | ||
| 50 | 50 | askSentence, |
| 51 | 51 | ASK_WORDS, |
| 52 | 52 | ROW_PLURAL, |
| 53 | decisionButtons, | |
| 54 | decisionForm, | |
| 55 | decisionsFor, | |
| 56 | 53 | forgeNeedsFor, |
| 57 | 54 | silencedRepos, |
| 58 | 55 | putAsideWaves, |
| @@ -60,6 +57,7 @@ import { | ||
| 60 | 57 | type Consequence, |
| 61 | 58 | type Decision, |
| 62 | 59 | } from "./asks.ts"; |
| 60 | import { decisionButtons, decisionForm, decisionsFor } from "./decisions.ts"; | |
| 63 | 61 | import { changeHref } from "./nouns.ts"; |
| 64 | 62 | |
| 65 | 63 | /** The kinds, in a word each, for "select by kind". */ |
src/web/studio/change.ts +2 −7
| @@ -76,13 +76,8 @@ import { | ||
| 76 | 76 | parseNumber, |
| 77 | 77 | type Entry, |
| 78 | 78 | } from "../pages/thread.ts"; |
| 79 | import { | |
| 80 | decisionActions, | |
| 81 | decisionForm, | |
| 82 | decisionsFor, | |
| 83 | KIND_TIER, | |
| 84 | type Decision, | |
| 85 | } from "./asks.ts"; | |
| 79 | import { KIND_TIER, type Decision } from "./asks.ts"; | |
| 80 | import { decisionActions, decisionForm, decisionsFor } from "./decisions.ts"; | |
| 86 | 81 | import { tellLine, tellsAnswered } from "./branch_tell.ts"; |
| 87 | 82 | import { previewVision } from "../../crew/vision.ts"; |
| 88 | 83 | import { visionPreviewCard } from "./direction.ts"; |
src/web/studio/decisions.ts added +350
| @@ -0,0 +1,350 @@ | ||
| 1 | /** | |
| 2 | * The inbox's decision cards: `decisionsFor` words and pictures each ask | |
| 3 | * `asks.ts` finds, and `decisionForm` / `decisionButtons` draw what a person | |
| 4 | * can do about it. Every button posts to the route a person could have | |
| 5 | * posted to by hand (`actions.ts`); the asks themselves — which there are, | |
| 6 | * how they score, how they count — stay in `asks.ts`. | |
| 7 | */ | |
| 8 | ||
| 9 | import { previewEnv } from "../../apps/data.ts"; | |
| 10 | import { appsForRepos } from "../../core/apps.ts"; | |
| 11 | import { issueTitles } from "../../core/proposals.ts"; | |
| 12 | import { looksForApps, lookUrl } from "../../core/looks.ts"; | |
| 13 | import type { Pull } from "../../core/pulls.ts"; | |
| 14 | import type { Repo } from "../../core/repos.ts"; | |
| 15 | import { handoverLines } from "../../crew/guard.ts"; | |
| 16 | import type { Ctx } from "../../http/context.ts"; | |
| 17 | import { repoBase } from "../chrome.ts"; | |
| 18 | import { html, type Html } from "../html.ts"; | |
| 19 | import { btn } from "../ui.ts"; | |
| 20 | import { thumbLook } from "../pages/looks.ts"; | |
| 21 | import { changeHref } from "./nouns.ts"; | |
| 22 | import { testsClause } from "./words.ts"; | |
| 23 | import { reviewWhy, scoredFor, waitingOn, type Decision, type Scored } from "./asks.ts"; | |
| 24 | ||
| 25 | /** Dollars as a person reads them: cents when small, whole when not. */ | |
| 26 | const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0)); | |
| 27 | ||
| 28 | /** | |
| 29 | * The decisions waiting on the viewer, worded and pictured. A change's own | |
| 30 | * page passes `evenDismissed`: setting an ask aside quiets the lists, never | |
| 31 | * the decision on the thing itself. | |
| 32 | */ | |
| 33 | export function decisionsFor( | |
| 34 | ctx: Ctx, | |
| 35 | allRepos: readonly Repo[], | |
| 36 | options: { evenDismissed?: boolean } = {}, | |
| 37 | ): Decision[] { | |
| 38 | const db = ctx.app.db; | |
| 39 | const candidates = options.evenDismissed | |
| 40 | ? scoredFor(db, ctx.auth.user, allRepos) | |
| 41 | : waitingOn(db, ctx.auth.user, allRepos); | |
| 42 | // The pictures, in one read: a green pull's row leads with its preview. | |
| 43 | const repoIds = [...new Set(candidates.map((c) => c.repo.id))]; | |
| 44 | const records = appsForRepos(db, repoIds); | |
| 45 | const looks = looksForApps( | |
| 46 | db, | |
| 47 | [...records.values()].map((r) => r.id), | |
| 48 | ); | |
| 49 | const pictureOf = (repo: Repo, pull: Pull): string | undefined => { | |
| 50 | const record = records.get(repo.id); | |
| 51 | if (record === undefined) return undefined; | |
| 52 | const env = previewEnv(pull.number); | |
| 53 | const look = thumbLook( | |
| 54 | ctx, | |
| 55 | repo, | |
| 56 | record, | |
| 57 | looks.filter((l) => l.appId === record.id && l.env === env), | |
| 58 | ); | |
| 59 | return ( | |
| 60 | look && lookUrl(repoBase(repo), look) | |
| 61 | ); | |
| 62 | }; | |
| 63 | const worded = (c: Scored): Omit<Decision, "consequence" | "waiting"> => { | |
| 64 | if (c.kind === "assigned") { | |
| 65 | const thing = c.pull ?? c.issue!; | |
| 66 | return { | |
| 67 | kind: "assigned", | |
| 68 | id: `assigned:${thing.id}`, | |
| 69 | repo: c.repo, | |
| 70 | ...(c.pull === undefined ? { issue: c.issue } : { pull: c.pull }), | |
| 71 | who: c.who, | |
| 72 | says: `"${thing.title}" is assigned to you.`, | |
| 73 | short: `assigned to you · updated ${thing.updatedAt.slice(0, 10)}`, | |
| 74 | }; | |
| 75 | } | |
| 76 | if (c.kind === "proposal" && c.proposal) | |
| 77 | return { | |
| 78 | kind: "proposal", | |
| 79 | id: `proposal:${c.proposal.id}`, | |
| 80 | repo: c.repo, | |
| 81 | proposal: c.proposal, | |
| 82 | who: c.who, | |
| 83 | says: `${c.who} proposes "${c.proposal.title}" (${issueTitles(c.proposal).length} issues). Approve opens them, Redirect hands your words to the planner, Later puts it aside.`, | |
| 84 | short: `proposes "${c.proposal.title}"`, | |
| 85 | }; | |
| 86 | if (c.kind === "parked" && c.issue) | |
| 87 | return { | |
| 88 | kind: "parked", | |
| 89 | id: `parked:${c.issue.id}`, | |
| 90 | repo: c.repo, | |
| 91 | issue: c.issue, | |
| 92 | who: c.who, | |
| 93 | says: `Three runs on #${c.issue.number} failed or were declined within an hour, so the crew parked it. Say what to try and it starts again.`, | |
| 94 | short: `#${c.issue.number} parked after three failed runs`, | |
| 95 | }; | |
| 96 | if (c.kind === "declined" && c.issue && c.decline) | |
| 97 | return { | |
| 98 | kind: "declined", | |
| 99 | id: `declined:${c.decline.runId}`, | |
| 100 | repo: c.repo, | |
| 101 | issue: c.issue, | |
| 102 | who: c.who, | |
| 103 | says: `The planner declined the direction: \u201c${c.decline.words}\u201d. It plans nothing more until you answer on #${c.issue.number}.`, | |
| 104 | short: `the planner declined the direction: \u201c${c.decline.words}\u201d`, | |
| 105 | }; | |
| 106 | if (c.kind === "handover" && c.pull && c.handover) | |
| 107 | return { | |
| 108 | kind: "handover", | |
| 109 | id: `handover:${c.pull.id}`, | |
| 110 | repo: c.repo, | |
| 111 | pull: c.pull, | |
| 112 | who: c.who, | |
| 113 | says: `The reviewer asked for changes three times with no approval between, so the crew stopped. ${handoverLines(c.handover).join(" ")} Say what to try (or approve it on its page), or close it.`, | |
| 114 | short: `"${c.pull.title}" had three reviews asking for changes`, | |
| 115 | why: c.why, | |
| 116 | recommendation: c.recommendation, | |
| 117 | giveBack: c.builder, | |
| 118 | }; | |
| 119 | if (c.kind === "waiting" && c.pull) | |
| 120 | return { | |
| 121 | kind: "waiting", | |
| 122 | id: `waiting:${c.pull.id}`, | |
| 123 | repo: c.repo, | |
| 124 | pull: c.pull, | |
| 125 | who: c.who, | |
| 126 | says: `"${c.pull.title}" is ${c.why ?? "waiting"}, and no person has acted on it. Ship it, say what to change, or close it.`, | |
| 127 | short: c.why ?? "waiting for a person", | |
| 128 | why: c.why, | |
| 129 | recommendation: c.recommendation, | |
| 130 | }; | |
| 131 | if (c.kind === "stopped" && c.pull) { | |
| 132 | const tries = c.tries ?? 0; | |
| 133 | const stopped = c.stoppedByDecider ?? false; | |
| 134 | return { | |
| 135 | kind: "stopped", | |
| 136 | id: `stopped:${c.pull.id}`, | |
| 137 | repo: c.repo, | |
| 138 | pull: c.pull, | |
| 139 | who: c.who, | |
| 140 | says: stopped | |
| 141 | ? `${c.who}'s check failed and the crew's decider stopped rather than spend another run. Say what to try, or close it.` | |
| 142 | : `${c.who} tried ${tries + 1} times and the check still fails. The crew stopped here. Say what to try, or close it.`, | |
| 143 | short: stopped | |
| 144 | ? `the check failed; the decider stopped for you` | |
| 145 | : `${c.who} tried ${tries + 1} times; the check still fails`, | |
| 146 | }; | |
| 147 | } | |
| 148 | if (c.kind === "review" && c.pull) | |
| 149 | return { | |
| 150 | kind: "review", | |
| 151 | id: `review:${c.pull.id}`, | |
| 152 | repo: c.repo, | |
| 153 | pull: c.pull, | |
| 154 | who: c.who, | |
| 155 | says: `${c.who}'s "${c.pull.title}" is green and waits for a review: ${reviewWhy(c.reviewWait)}. An agent's change never ships unreviewed; ship it, or say what is off.`, | |
| 156 | short: `green; waits for a review — ${reviewWhy(c.reviewWait)}`, | |
| 157 | }; | |
| 158 | if (c.kind === "look" && c.pull) { | |
| 159 | const picture = pictureOf(c.repo, c.pull); | |
| 160 | const clause = testsClause(c.check ?? null); | |
| 161 | // The crew's own words say who finished it; a person's own change | |
| 162 | // gets the wording that is true of it — nobody "finished" it for | |
| 163 | // them, it is simply green and waiting. | |
| 164 | return { | |
| 165 | kind: "look", | |
| 166 | id: `look:${c.pull.id}`, | |
| 167 | repo: c.repo, | |
| 168 | pull: c.pull, | |
| 169 | who: c.who, | |
| 170 | says: c.authorIsAgent | |
| 171 | ? `${c.who} finished "${c.pull.title}" and the check is green${clause === "" ? "" : ` (${clause})`}. Look at the preview, then ship it or say what is off.` | |
| 172 | : `"${c.pull.title}" is green — ${clause === "" ? "" : `${clause}, `}ready for a look. Ship it, or say what is off.`, | |
| 173 | short: c.authorIsAgent | |
| 174 | ? `${c.who} finished it; the check is green${clause === "" ? "" : ` (${clause})`}` | |
| 175 | : `green — ${clause === "" ? "" : `${clause}, `}ready for a look`, | |
| 176 | ...(picture === undefined ? {} : { picture }), | |
| 177 | }; | |
| 178 | } | |
| 179 | return { | |
| 180 | kind: "paused", | |
| 181 | id: `paused:${c.repo.id}`, | |
| 182 | repo: c.repo, | |
| 183 | who: "the crew", | |
| 184 | says: `${c.repo.name} reached its $${dollars(c.repo.autonomyCeilingUsd)} for today. The crew picks nothing more until 00:00 UTC. Raise the ceiling in settings to go on now.`, | |
| 185 | short: `at its $${dollars(c.repo.autonomyCeilingUsd)} ceiling until 00:00 UTC`, | |
| 186 | }; | |
| 187 | }; | |
| 188 | const previewOf = (c: Scored): string | undefined => | |
| 189 | c.pull === undefined || !records.has(c.repo.id) | |
| 190 | ? undefined | |
| 191 | : `${repoBase(c.repo)}/app/pull-${c.pull.number}/open`; | |
| 192 | return candidates.map((c) => ({ | |
| 193 | ...worded(c), | |
| 194 | preview: previewOf(c), | |
| 195 | consequence: c.consequence, | |
| 196 | waiting: c.waiting, | |
| 197 | })); | |
| 198 | } | |
| 199 | ||
| 200 | /** The id of the form a row's own button submits (see `decisionForm`). */ | |
| 201 | const askFormId = (d: Decision): string => `ask-${d.id}`; | |
| 202 | ||
| 203 | /** | |
| 204 | * The form behind a row's landing button — its own post, exactly as it was. | |
| 205 | * It sits after the panel's form, because a form inside a form is not | |
| 206 | * HTML; the button reaches it by id. | |
| 207 | */ | |
| 208 | export function decisionForm(d: Decision, back?: string): Html | null { | |
| 209 | const pull = d.pull; | |
| 210 | const own = html`id="${askFormId(d)}" method="post" hidden`; | |
| 211 | if (d.kind === "parked") | |
| 212 | return html`<form ${own} action="/asks/decide"> | |
| 213 | <input type="hidden" name="ask" value="${d.id}" /> | |
| 214 | </form>`; | |
| 215 | if (d.kind === "proposal" && d.proposal) return proposalForms(d, back); | |
| 216 | if (pull === undefined) return null; | |
| 217 | const base = repoBase(d.repo); | |
| 218 | const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`; | |
| 219 | if (d.kind === "handover" && d.giveBack !== undefined) | |
| 220 | return html`<form ${own} action="${base}/pulls/${pull.number}/state"> | |
| 221 | <input type="hidden" name="state" value="closed" />${where} | |
| 222 | </form> | |
| 223 | <form id="${askFormId(d)}-back" method="post" hidden action="/asks/give-back"> | |
| 224 | <input type="hidden" name="ask" value="${d.id}" />${where} | |
| 225 | </form>`; | |
| 226 | if (d.kind === "stopped" || d.kind === "handover") | |
| 227 | return html`<form ${own} action="${base}/pulls/${pull.number}/state"> | |
| 228 | <input type="hidden" name="state" value="closed" />${where} | |
| 229 | </form>`; | |
| 230 | if (d.kind === "look" || d.kind === "review") | |
| 231 | return html`<form ${own} action="${base}/pulls/${pull.number}/ship"> | |
| 232 | <input type="hidden" name="pull" value="${pull.number}" />${where} | |
| 233 | </form>`; | |
| 234 | if (d.kind === "waiting") | |
| 235 | return html`<form ${own} action="${base}/pulls/${pull.number}/ship"> | |
| 236 | <input type="hidden" name="pull" value="${pull.number}" />${where} | |
| 237 | </form> | |
| 238 | <form id="${askFormId(d)}-close" method="post" hidden action="${base}/pulls/${pull.number}/state"> | |
| 239 | <input type="hidden" name="state" value="closed" />${where} | |
| 240 | </form>`; | |
| 241 | return null; | |
| 242 | } | |
| 243 | ||
| 244 | /** | |
| 245 | * The actions on one ask, on one line: a button for what lands (submitting | |
| 246 | * the row's own form, `decisionForm`), links for what needs a look or a | |
| 247 | * word. Saying something to the crew happens on the change's own page, | |
| 248 | * where the conversation is — not in a box in a list. | |
| 249 | */ | |
| 250 | export function decisionActions(ctx: Ctx, d: Decision): Html { | |
| 251 | const { primary, others } = decisionButtons(ctx, d); | |
| 252 | return html`${primary} ${others}`; | |
| 253 | } | |
| 254 | ||
| 255 | /** An ask's actions in two parts: the one that decides, and the rest. */ | |
| 256 | export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others: Html[] } { | |
| 257 | const base = repoBase(d.repo); | |
| 258 | const pull = d.pull; | |
| 259 | const preview = d.preview; | |
| 260 | if ((d.kind === "stopped" || d.kind === "handover") && pull) | |
| 261 | return { | |
| 262 | primary: btn("Tell them", { | |
| 263 | href: `${changeHref(base, pull.number)}#comment-body`, | |
| 264 | size: "sm", | |
| 265 | variant: "primary", | |
| 266 | }), | |
| 267 | others: [ | |
| 268 | ...(d.kind === "handover" && d.giveBack !== undefined | |
| 269 | ? [btn("Give back to the crew", { size: "sm", form: `${askFormId(d)}-back` })] | |
| 270 | : []), | |
| 271 | btn("Close", { size: "sm", form: askFormId(d) }), | |
| 272 | btn("See", { href: changeHref(base, pull.number), size: "sm" }), | |
| 273 | ], | |
| 274 | }; | |
| 275 | if ((d.kind === "look" || d.kind === "review") && pull) | |
| 276 | return { | |
| 277 | primary: btn("Ship", { size: "sm", form: askFormId(d), variant: "primary" }), | |
| 278 | others: [ | |
| 279 | ...(preview === undefined ? [] : [btn("Preview", { href: preview, size: "sm" })]), | |
| 280 | btn("Not like this", { | |
| 281 | href: `${changeHref(base, pull.number)}#comment-body`, | |
| 282 | size: "sm", | |
| 283 | }), | |
| 284 | ], | |
| 285 | }; | |
| 286 | if (d.kind === "waiting" && pull) | |
| 287 | return { | |
| 288 | primary: btn("Ship", { size: "sm", form: askFormId(d), variant: "primary" }), | |
| 289 | others: [ | |
| 290 | btn("Say what to change", { | |
| 291 | href: `${changeHref(base, pull.number)}#comment-body`, | |
| 292 | size: "sm", | |
| 293 | }), | |
| 294 | btn("Close", { size: "sm", form: `${askFormId(d)}-close` }), | |
| 295 | ], | |
| 296 | }; | |
| 297 | if (d.kind === "assigned" && (d.pull ?? d.issue)) | |
| 298 | return { | |
| 299 | primary: btn("Open", { | |
| 300 | href: pull ? changeHref(base, pull.number) : `${base}/issues/${d.issue!.number}`, | |
| 301 | size: "sm", | |
| 302 | variant: "primary", | |
| 303 | }), | |
| 304 | others: [], | |
| 305 | }; | |
| 306 | if (d.kind === "proposal" && d.proposal) | |
| 307 | return { | |
| 308 | primary: btn("Approve", { size: "sm", form: askFormId(d), variant: "primary" }), | |
| 309 | others: [btn("Later", { size: "sm", form: `${askFormId(d)}-later` })], | |
| 310 | }; | |
| 311 | if (d.kind === "declined" && d.issue) | |
| 312 | return { | |
| 313 | primary: btn("Answer", { | |
| 314 | href: `${base}/issues/${d.issue.number}#comment-body`, | |
| 315 | size: "sm", | |
| 316 | variant: "primary", | |
| 317 | }), | |
| 318 | others: [], | |
| 319 | }; | |
| 320 | if (d.kind === "parked" && d.issue) | |
| 321 | return { | |
| 322 | primary: btn("Tell them", { | |
| 323 | href: `${base}/issues/${d.issue.number}#comment-body`, | |
| 324 | size: "sm", | |
| 325 | variant: "primary", | |
| 326 | }), | |
| 327 | // Only where a decider is there to answer. | |
| 328 | others: | |
| 329 | ctx.app.config.decider === undefined | |
| 330 | ? [] | |
| 331 | : [btn("Let the decider", { size: "sm", form: askFormId(d) })], | |
| 332 | }; | |
| 333 | return { | |
| 334 | primary: btn("Settings", { href: `${base}/settings/autonomy`, size: "sm" }), | |
| 335 | others: [], | |
| 336 | }; | |
| 337 | } | |
| 338 | ||
| 339 | /** | |
| 340 | * The forms behind a proposal card: Approve, Later and Redirect, each one | |
| 341 | * POST to `/asks/proposal/:id/<verb>` that lands back on the page it came | |
| 342 | * from. They sit apart from the card's own form, as forms do not nest. | |
| 343 | */ | |
| 344 | function proposalForms(d: Decision, back?: string): Html { | |
| 345 | const id = askFormId(d); | |
| 346 | const post = (verb: string) => `/asks/proposal/${d.proposal!.id}/${verb}`; | |
| 347 | const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`; | |
| 348 | return html`<form id="${id}" method="post" action="${post("approve")}" hidden>${where}</form> | |
| 349 | <form id="${id}-later" method="post" action="${post("later")}" hidden>${where}</form>`; | |
| 350 | } |
test/asks.test.ts +1 −1
| @@ -21,7 +21,7 @@ import { getUserByName } from "../src/core/users.ts"; | ||
| 21 | 21 | import type { Ctx } from "../src/http/context.ts"; |
| 22 | 22 | import { serve, type Servers } from "../src/http/server.ts"; |
| 23 | 23 | import { setCapacity, storedCapacity } from "../src/core/capacity.ts"; |
| 24 | import { decisionsFor } from "../src/web/studio/asks.ts"; | |
| 24 | import { decisionsFor } from "../src/web/studio/decisions.ts"; | |
| 25 | 25 | import { quietLogs } from "./helpers/globals.ts"; |
| 26 | 26 | import { adminCookie } from "./helpers/admin.ts"; |
| 27 | 27 |
test/crew-loop.test.ts +2 −1
| @@ -28,7 +28,8 @@ import { startCrew, queueAgentRun, type CrewHandle } from "../src/crew/dispatch. | ||
| 28 | 28 | import { getAgent } from "../src/core/agents.ts"; |
| 29 | 29 | import type { Ctx } from "../src/http/context.ts"; |
| 30 | 30 | import { setIssueState } from "../src/actions/issues.ts"; |
| 31 | import { ASK_WORDS, KIND_TIER, askLinesFor, decisionActions, decisionForm, decisionsFor, endOf } from "../src/web/studio/asks.ts"; | |
| 31 | import { ASK_WORDS, KIND_TIER, askLinesFor, endOf } from "../src/web/studio/asks.ts"; | |
| 32 | import { decisionActions, decisionForm, decisionsFor } from "../src/web/studio/decisions.ts"; | |
| 32 | 33 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 33 | 34 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 34 | 35 | import { settleApp } from "./helpers/tui.ts"; |
new above
offline