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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/6311-the-inbox-s-decision-forms-leave-asks-ts → main 4 commits fork

  • Merged. mason/6311-the-inbox-s-decision-forms-leave-asks-ts was merged into main as e39d370.

Review

Sign in to review this change.
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
333333 forge's health (`needs` in `/api/v1/health`). What counts as an ask is
334334 defined once, in `src/web/studio/asks.ts`: the header's **Asks** count, the
335335 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,
337338 so it works without JavaScript.
338339
339340 ## What you asked for
src/web/studio/asks.ts +7 −342
@@ -2,15 +2,14 @@
22 * What asks a person, and what they can do about it: a *decision* is one
33 * card — a pull the crew stopped on, a green pull waiting for a look, or a
44 * 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`.
88 */
99
1010 import type { Database } from "bun:sqlite";
1111 import { forgeVitals, needLine } from "../../actions/health.ts";
1212 import type { Degraded } from "../../kernel/vitals.ts";
13 import { previewEnv } from "../../apps/data.ts";
1413 import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts";
1514 import { crewForRepos } from "../../core/agents.ts";
1615 import { appsForRepos } from "../../core/apps.ts";
@@ -31,7 +30,6 @@ import {
3130 type Issue,
3231 } from "../../core/issues.ts";
3332 import { mayView } from "../../core/sealed.ts";
34 import { looksForApps } from "../../core/looks.ts";
3533 import { closesFromBody, getPull, joinMany, type Pull } from "../../core/pulls.ts";
3634 import {
3735 canRead,
@@ -56,18 +54,11 @@ import {
5654 } from "../../crew/autonomy.ts";
5755 import { breakerParked, WAITING_NOTES } from "../../crew/breaker.ts";
5856 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";
6058 import { conflictsOf, isStalePull, mergesAgainSince, staleGreen } from "../../crew/stale_asks.ts";
6159 import { reviewWaitsFor, type ReviewWait } from "../../crew/review.ts";
6260 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";
6961 import { NO_LONGER_ASKS } from "../../actions/asks_words.ts";
70 import { testsClause } from "./words.ts";
7162
7263 /** Repos narrowed to those with an app record, with the records — the one
7364 * join `appsOf` and `writableAppsOf` share. */
@@ -110,9 +101,6 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) {
110101
111102 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "waiting" | "assigned" | "proposal";
112103
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
116104 export interface Decision {
117105 readonly kind: DecisionKind;
118106 /** Stable across renders — the kind and what it is about — so a form can
@@ -788,7 +776,7 @@ export function askRows<
788776 }));
789777 }
790778
791 type Scored = AskCandidate & {
779 export type Scored = AskCandidate & {
792780 readonly consequence: Consequence;
793781 readonly waiting: WaitingFor;
794782 };
@@ -805,7 +793,7 @@ function waitingOf(db: Database, user: User | undefined, a: AskCandidate): Waiti
805793 }
806794
807795 /** Every candidate for the viewer, scored, dismissed or not. */
808 function scoredFor(
796 export function scoredFor(
809797 db: Database,
810798 user: User | undefined,
811799 repos: readonly Repo[],
@@ -978,7 +966,7 @@ function lineOf(c: AskCandidate): string {
978966 }
979967
980968 /** 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 =>
982970 wait?.why === "rounds"
983971 ? `the reviewer asked for changes three times, since ${wait.since.slice(0, 10)}`
984972 : wait?.why === "lapsed" && wait.lateCheckMinutes !== undefined
@@ -1100,316 +1088,6 @@ export function askLinesFor(
11001088 });
11011089 }
11021090
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
14131091 /**
14141092 * What the forge itself needs, for a site admin: each sentence of the
14151093 * health answer (`/api/v1/health`) as one ask. Anyone else gets none — the
@@ -1543,16 +1221,3 @@ export function askTargetsFor(
15431221 }
15441222 return { issueIds: [...new Set(issueIds)], refused };
15451223 }
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 {
5050 askSentence,
5151 ASK_WORDS,
5252 ROW_PLURAL,
53 decisionButtons,
54 decisionForm,
55 decisionsFor,
5653 forgeNeedsFor,
5754 silencedRepos,
5855 putAsideWaves,
@@ -60,6 +57,7 @@ import {
6057 type Consequence,
6158 type Decision,
6259 } from "./asks.ts";
60 import { decisionButtons, decisionForm, decisionsFor } from "./decisions.ts";
6361 import { changeHref } from "./nouns.ts";
6462
6563 /** The kinds, in a word each, for "select by kind". */
src/web/studio/change.ts +2 −7
@@ -76,13 +76,8 @@ import {
7676 parseNumber,
7777 type Entry,
7878 } 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";
8681 import { tellLine, tellsAnswered } from "./branch_tell.ts";
8782 import { previewVision } from "../../crew/vision.ts";
8883 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";
2121 import type { Ctx } from "../src/http/context.ts";
2222 import { serve, type Servers } from "../src/http/server.ts";
2323 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";
2525 import { quietLogs } from "./helpers/globals.ts";
2626 import { adminCookie } from "./helpers/admin.ts";
2727
test/crew-loop.test.ts +2 −1
@@ -28,7 +28,8 @@ import { startCrew, queueAgentRun, type CrewHandle } from "../src/crew/dispatch.
2828 import { getAgent } from "../src/core/agents.ts";
2929 import type { Ctx } from "../src/http/context.ts";
3030 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";
3233 import { createForgeApp } from "../src/tui/forge.ts";
3334 import { headlessTerminal } from "../src/tui/term/terminal.ts";
3435 import { settleApp } from "./helpers/tui.ts";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs