Public: anyone can read this; the crew can change it.
The studio home answers without waiting on the decider #38
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/34-the-studio-home-answers-without-waiting → main 1 commit fork
5 files
changed +430
−38
docs/STUDIO.md +9
| @@ -60,6 +60,15 @@ the green ones, close the stopped ones — as one decision (`POST | ||
| 60 | 60 | never leaves the page: the row looks busy until it lands, then goes; one |
| 61 | 61 | that could not be done stays, with the reason on it. |
| 62 | 62 | |
| 63 | When a decider is configured, an ask card's own option can carry a **lean** | |
| 64 | — a small word (_likely_, _probably_, _maybe_) beside the option a careful | |
| 65 | owner would pick, from asking the decider the same question a person is | |
| 66 | looking at. The lean is never why the page waits: a card renders with | |
| 67 | whatever lean is already known, and a card with none yet is asked for after | |
| 68 | the page is on its way to the browser, so the answer is there on a later | |
| 69 | render, not this one. A forge with no decider configured shows no lean and | |
| 70 | asks nothing, exactly as before deciders existed. | |
| 71 | ||
| 63 | 72 | **Now** lists what was told and not yet picked up, every open pull with its |
| 64 | 73 | state, and the last runs, each in one of the five words with who and when. |
| 65 | 74 | A run in flight carries _Stop_ (running) or _Cancel_ (waiting) for anyone |
src/core/decisions.ts +41 −1
| @@ -9,7 +9,10 @@ | ||
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { id as newId } from "../kernel/id.ts"; |
| 11 | 11 | |
| 12 | export type DecisionKind = "next" | "land" | "retry"; | |
| 12 | /** `lean` is which option on a studio ask card a careful owner would pick | |
| 13 | * — see `web/pages/studio.ts`, which stores `{ card, head }` in `state` so | |
| 14 | * `latestLean` can tell a fresh answer from one about a head that moved. */ | |
| 15 | export type DecisionKind = "next" | "land" | "retry" | "lean"; | |
| 13 | 16 | |
| 14 | 17 | export interface Decision { |
| 15 | 18 | readonly id: string; |
| @@ -143,6 +146,43 @@ export function resolveOpen( | ||
| 143 | 146 | return true; |
| 144 | 147 | } |
| 145 | 148 | |
| 149 | /** | |
| 150 | * The newest `lean` decision still about the same head: the option a | |
| 151 | * decider offered for one studio ask card, good only as long as the head it | |
| 152 | * was asked about. `head` is a pull's head oid, or `""` for a card about a | |
| 153 | * repository rather than a pull. A handful of rows back covers a card whose | |
| 154 | * kind changed (the same issue asked as `stopped`, then as `look`) without | |
| 155 | * a join on anything but what is already indexed. | |
| 156 | */ | |
| 157 | export function latestLean( | |
| 158 | db: Database, | |
| 159 | repoId: string, | |
| 160 | issueId: string | null, | |
| 161 | card: string, | |
| 162 | head: string, | |
| 163 | ): Decision | undefined { | |
| 164 | const rows = | |
| 165 | issueId === null | |
| 166 | ? db | |
| 167 | .query< | |
| 168 | Row, | |
| 169 | [string] | |
| 170 | >("SELECT * FROM decision WHERE kind = 'lean' AND issue_id IS NULL AND repo_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 10") | |
| 171 | .all(repoId) | |
| 172 | : db | |
| 173 | .query< | |
| 174 | Row, | |
| 175 | [string] | |
| 176 | >("SELECT * FROM decision WHERE kind = 'lean' AND issue_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 10") | |
| 177 | .all(issueId); | |
| 178 | return rows.map(toDecision).find((d) => { | |
| 179 | const s = d.state as { card?: unknown; head?: unknown } | null; | |
| 180 | return ( | |
| 181 | typeof s === "object" && s !== null && s.card === card && s.head === head | |
| 182 | ); | |
| 183 | }); | |
| 184 | } | |
| 185 | ||
| 146 | 186 | export function listDecisions( |
| 147 | 187 | db: Database, |
| 148 | 188 | repoId: string, |
src/web/pages/studio.ts +105 −36
| @@ -49,11 +49,16 @@ import { | ||
| 49 | 49 | } from "../../core/runs.ts"; |
| 50 | 50 | import { getUserById, getUsersByIds, type User } from "../../core/users.ts"; |
| 51 | 51 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 52 | import { latestDecision } from "../../core/decisions.ts"; | |
| 52 | import { | |
| 53 | latestDecision, | |
| 54 | latestLean, | |
| 55 | recordDecision, | |
| 56 | } from "../../core/decisions.ts"; | |
| 53 | 57 | import { routeIssue } from "../../crew/route.ts"; |
| 54 | import { certain, decideOr } from "../../crew/decide.ts"; | |
| 58 | import { certain, decideOr, type Decider } from "../../crew/decide.ts"; | |
| 55 | 59 | import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts"; |
| 56 | 60 | import type { Ctx } from "../../http/context.ts"; |
| 61 | import { createLog } from "../../kernel/log.ts"; | |
| 57 | 62 | import { |
| 58 | 63 | crumbsFor, |
| 59 | 64 | loadRepo, |
| @@ -124,28 +129,46 @@ const CARD_OPTIONS: Record<DecisionKind, Readonly<Record<string, string>>> = { | ||
| 124 | 129 | paused: { settings: "Raise the ceiling.", wait: "Wait for midnight." }, |
| 125 | 130 | }; |
| 126 | 131 | |
| 132 | const log = createLog("studio"); | |
| 133 | ||
| 134 | /** A pull's head oid, or "" for a card with no commit to key a lean on. */ | |
| 135 | const headOf = (d: Decision): string => d.pull?.headOid ?? ""; | |
| 136 | ||
| 137 | /** One background ask per card+head, so a page rendered by many people at | |
| 138 | * once never fans out past one call per card. Cleared once answered. */ | |
| 139 | const leanInFlight = new Map<string, Promise<void>>(); | |
| 140 | ||
| 141 | /** A pause before asking, long enough that the response for the render | |
| 142 | * which triggered the ask is on its way out first — the point is that the | |
| 143 | * decider is never on the request's critical path, not that the ask is | |
| 144 | * instant. */ | |
| 145 | const LEAN_ASK_DELAY_MS = 50; | |
| 146 | ||
| 127 | 147 | /** |
| 128 | * Ask the decider which option on each card is likely right, so the card | |
| 129 | * can lead with it. Bounded to a short wait: a page is being drawn. | |
| 148 | * Ask the decider which option on a card is likely right, off the request | |
| 149 | * path — after this turn of the event loop, never inside it — and remember | |
| 150 | * the answer keyed to the head it was about. A rule's answer is not worth | |
| 151 | * remembering: it is what the page would have shown anyway. | |
| 130 | 152 | */ |
| 131 | export async function withLikely( | |
| 132 | ctx: Ctx, | |
| 133 | decisions: readonly Decision[], | |
| 134 | ): Promise<Decision[]> { | |
| 135 | const decider = ctx.app.config.decider; | |
| 136 | if (decider === undefined || decisions.length === 0) return [...decisions]; | |
| 137 | return Promise.all( | |
| 138 | decisions.map(async (d) => { | |
| 139 | const options = Object.keys(CARD_OPTIONS[d.kind]); | |
| 140 | const decided = await decideOr( | |
| 153 | function scheduleLean(ctx: Ctx, d: Decision, decider: Decider): void { | |
| 154 | const head = headOf(d); | |
| 155 | const key = `${d.id}@${head}`; | |
| 156 | if (leanInFlight.has(key)) return; | |
| 157 | const options = Object.keys(CARD_OPTIONS[d.kind]); | |
| 158 | const state = { | |
| 159 | card: d.kind, | |
| 160 | head, | |
| 161 | repository: `${d.repo.ownerName}/${d.repo.name}`, | |
| 162 | situation: d.says, | |
| 163 | ...(d.pull === undefined | |
| 164 | ? {} | |
| 165 | : { change: d.pull.title, description: d.pull.body.slice(0, 800) }), | |
| 166 | }; | |
| 167 | const asked = new Promise<void>((resolve) => { | |
| 168 | setTimeout(() => { | |
| 169 | decideOr( | |
| 141 | 170 | decider, |
| 142 | { | |
| 143 | repository: `${d.repo.ownerName}/${d.repo.name}`, | |
| 144 | situation: d.says, | |
| 145 | ...(d.pull === undefined | |
| 146 | ? {} | |
| 147 | : { change: d.pull.title, description: d.pull.body.slice(0, 800) }), | |
| 148 | }, | |
| 171 | state, | |
| 149 | 172 | { |
| 150 | 173 | option: { |
| 151 | 174 | options, |
| @@ -154,19 +177,65 @@ export async function withLikely( | ||
| 154 | 177 | }, |
| 155 | 178 | }, |
| 156 | 179 | () => ({ option: certain(options, options[0]!) }), |
| 157 | 800, | |
| 158 | ); | |
| 159 | return decided.by === "rule" | |
| 160 | ? d | |
| 161 | : { | |
| 162 | ...d, | |
| 163 | likely: { | |
| 164 | option: decided.answers.option.choice, | |
| 165 | p: decided.answers.option.p, | |
| 166 | }, | |
| 167 | }; | |
| 168 | }), | |
| 169 | ); | |
| 180 | ) | |
| 181 | .then((decided) => { | |
| 182 | if (decided.by === "rule") return; | |
| 183 | recordDecision(ctx.app.db, { | |
| 184 | kind: "lean", | |
| 185 | repoId: d.repo.id, | |
| 186 | issueId: d.pull?.id, | |
| 187 | state, | |
| 188 | answer: decided.answers.option.choice, | |
| 189 | p: decided.answers.option.p, | |
| 190 | by: decided.by, | |
| 191 | ms: decided.ms, | |
| 192 | }); | |
| 193 | }) | |
| 194 | .catch((error) => | |
| 195 | log.warn("lean not recorded", { id: d.id, error: String(error) }), | |
| 196 | ) | |
| 197 | .finally(() => { | |
| 198 | leanInFlight.delete(key); | |
| 199 | resolve(); | |
| 200 | }); | |
| 201 | }, LEAN_ASK_DELAY_MS); | |
| 202 | }); | |
| 203 | leanInFlight.set(key, asked); | |
| 204 | } | |
| 205 | ||
| 206 | /** Test hook: every ask scheduled so far, answered or not. The page itself | |
| 207 | * never waits on this — only a test that wants the next render to have it. */ | |
| 208 | export function pendingLeans(): Promise<unknown> { | |
| 209 | return Promise.allSettled([...leanInFlight.values()]); | |
| 210 | } | |
| 211 | ||
| 212 | /** | |
| 213 | * Every card with the lean already known about it, read with no wait; a | |
| 214 | * card whose lean is unknown, or whose head has moved past the one it was | |
| 215 | * asked about, is asked for off the request path (`scheduleLean`) so a | |
| 216 | * later render has it. A forge with no decider does nothing here, exactly | |
| 217 | * as before. | |
| 218 | */ | |
| 219 | export function withLikely( | |
| 220 | ctx: Ctx, | |
| 221 | decisions: readonly Decision[], | |
| 222 | ): Decision[] { | |
| 223 | const decider = ctx.app.config.decider; | |
| 224 | if (decider === undefined) return [...decisions]; | |
| 225 | return decisions.map((d) => { | |
| 226 | const lean = latestLean( | |
| 227 | ctx.app.db, | |
| 228 | d.repo.id, | |
| 229 | d.pull?.id ?? null, | |
| 230 | d.kind, | |
| 231 | headOf(d), | |
| 232 | ); | |
| 233 | if (lean === undefined) { | |
| 234 | scheduleLean(ctx, d, decider); | |
| 235 | return d; | |
| 236 | } | |
| 237 | return { ...d, likely: { option: lean.answer, p: lean.p } }; | |
| 238 | }); | |
| 170 | 239 | } |
| 171 | 240 | |
| 172 | 241 | /** When a person last spoke on a conversation, or its start. */ |
| @@ -471,7 +540,7 @@ export async function studioApp( | ||
| 471 | 540 | const i = getIssueById(db, r.issueId); |
| 472 | 541 | if (i) issues.set(r.issueId, i); |
| 473 | 542 | } |
| 474 | const decisions = await withLikely(ctx, decisionsFor(ctx, [repo])); | |
| 543 | const decisions = withLikely(ctx, decisionsFor(ctx, [repo])); | |
| 475 | 544 | const openPulls = listPulls(db, repo.id, { state: "open", limit: 10 }); |
| 476 | 545 | const pullChecks = latestRunsForIssues( |
| 477 | 546 | db, |
| @@ -850,7 +919,7 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 850 | 919 | .map((r) => r.actorId) |
| 851 | 920 | .filter((id): id is string => id !== null), |
| 852 | 921 | ); |
| 853 | const decisions = await withLikely(ctx, decisionsFor(ctx, appRepos)); | |
| 922 | const decisions = withLikely(ctx, decisionsFor(ctx, appRepos)); | |
| 854 | 923 | const asksByRepo = new Map<string, number>(); |
| 855 | 924 | for (const d of decisions) |
| 856 | 925 | asksByRepo.set(d.repo.id, (asksByRepo.get(d.repo.id) ?? 0) + 1); |
test/decide.test.ts +138 −1
| @@ -11,10 +11,13 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 13 | 13 | import { openIssue } from "../src/actions/issues.ts"; |
| 14 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { openPull } from "../src/actions/pulls.ts"; | |
| 15 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 15 | 16 | import { listCrewRoles, setCrewRole } from "../src/core/agents.ts"; |
| 16 | 17 | import { getIssueById } from "../src/core/issues.ts"; |
| 18 | import type { Pull } from "../src/core/pulls.ts"; | |
| 17 | 19 | import type { Repo } from "../src/core/repos.ts"; |
| 20 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 18 | 21 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 19 | 22 | import { |
| 20 | 23 | certain, |
| @@ -25,6 +28,11 @@ import { | ||
| 25 | 28 | type Decider, |
| 26 | 29 | } from "../src/crew/decide.ts"; |
| 27 | 30 | import { kindByWords, routeIssue, sizeByWords } from "../src/crew/route.ts"; |
| 31 | import { | |
| 32 | decisionsFor, | |
| 33 | pendingLeans, | |
| 34 | withLikely, | |
| 35 | } from "../src/web/pages/studio.ts"; | |
| 28 | 36 | |
| 29 | 37 | const Q = { |
| 30 | 38 | builder: { options: ["mason", "scout"], about: "who" }, |
| @@ -350,3 +358,132 @@ describe("routing an issue", () => { | ||
| 350 | 358 | withJev.close(); |
| 351 | 359 | }); |
| 352 | 360 | }); |
| 361 | ||
| 362 | describe("a studio card's lean", () => { | |
| 363 | let dataDir: string; | |
| 364 | let app: App; | |
| 365 | let alice: User; | |
| 366 | let repo: Repo; | |
| 367 | let pull: Pull; | |
| 368 | let calls: number; | |
| 369 | ||
| 370 | beforeAll(async () => { | |
| 371 | dataDir = await mkdtemp(join(tmpdir(), "forge-lean-")); | |
| 372 | calls = 0; | |
| 373 | const decider: Decider = { | |
| 374 | name: "fake", | |
| 375 | async decide(_state, questions) { | |
| 376 | calls++; | |
| 377 | const options = questions["option"]!.options; | |
| 378 | return { | |
| 379 | option: { | |
| 380 | choice: options[0]!, | |
| 381 | p: 0.9, | |
| 382 | probabilities: Object.fromEntries( | |
| 383 | options.map((o, i) => [o, i === 0 ? 0.9 : 0.05]), | |
| 384 | ), | |
| 385 | }, | |
| 386 | } as never; | |
| 387 | }, | |
| 388 | }; | |
| 389 | app = createApp({ dataDir, port: 0, decider }); | |
| 390 | const a = await createUser(app.db, { | |
| 391 | username: "alice", | |
| 392 | email: "[email protected]", | |
| 393 | password: "a-good-passphrase", | |
| 394 | adminIfFirst: true, | |
| 395 | }); | |
| 396 | if (!a.ok) throw new Error(a.error); | |
| 397 | alice = a.value; | |
| 398 | const made = await createRepo(app, alice, { | |
| 399 | name: "leans", | |
| 400 | visibility: "private", | |
| 401 | }); | |
| 402 | if (!made.ok) throw new Error(String(made.error)); | |
| 403 | repo = made.value; | |
| 404 | const seeded = await commitToBranch(app, alice, repo, { | |
| 405 | message: "Start", | |
| 406 | files: [{ path: "README.md", content: "hi\n" }], | |
| 407 | }); | |
| 408 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 409 | const scoutAgent = await createAgent(app, alice, { username: "scout" }); | |
| 410 | if (!scoutAgent.ok) throw new Error(String(scoutAgent.error)); | |
| 411 | expect(addToCrew(app, alice, repo, "scout").ok).toBe(true); | |
| 412 | const scout = getUserByName(app.db, "scout")!; | |
| 413 | const branch = await commitToBranch(app, alice, repo, { | |
| 414 | branch: "feature/x", | |
| 415 | base: "main", | |
| 416 | message: "Add x", | |
| 417 | files: [{ path: "x.md", content: "x\n" }], | |
| 418 | }); | |
| 419 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 420 | const opened = await openPull(app, scout, repo, { | |
| 421 | title: "Add x", | |
| 422 | body: "", | |
| 423 | baseBranch: "main", | |
| 424 | headBranch: "feature/x", | |
| 425 | }); | |
| 426 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 427 | pull = opened.value; | |
| 428 | const run = createRun(app.db, { | |
| 429 | repoId: repo.id, | |
| 430 | kind: "check", | |
| 431 | issueId: pull.id, | |
| 432 | env: `pull-${pull.number}`, | |
| 433 | commitOid: pull.headOid, | |
| 434 | reason: "pull", | |
| 435 | }); | |
| 436 | claimRun(app.db, run.id); | |
| 437 | finishRun(app.db, run.id, { state: "ok", summary: "check passed" }); | |
| 438 | }); | |
| 439 | ||
| 440 | afterAll(async () => { | |
| 441 | app.close(); | |
| 442 | await rm(dataDir, { recursive: true, force: true }); | |
| 443 | }); | |
| 444 | ||
| 445 | const ctx = () => ({ app, auth: { user: alice, via: "session" } }) as never; | |
| 446 | ||
| 447 | test("reading the card makes no decider call; the answer it asked for shows on the next read", async () => { | |
| 448 | const first = withLikely(ctx(), decisionsFor(ctx(), [repo])); | |
| 449 | const card = first.find((d) => d.pull?.id === pull.id); | |
| 450 | expect(card).toBeDefined(); | |
| 451 | expect(card?.likely).toBeUndefined(); | |
| 452 | expect(calls).toBe(0); | |
| 453 | await pendingLeans(); | |
| 454 | expect(calls).toBe(1); | |
| 455 | const second = withLikely(ctx(), decisionsFor(ctx(), [repo])); | |
| 456 | expect(second.find((d) => d.pull?.id === pull.id)?.likely).toEqual({ | |
| 457 | option: "ship", | |
| 458 | p: 0.9, | |
| 459 | }); | |
| 460 | expect(calls).toBe(1); | |
| 461 | }); | |
| 462 | ||
| 463 | test("a lean is dropped once the pull's head moves, and asked for again", async () => { | |
| 464 | app.db | |
| 465 | .query("UPDATE pull SET head_oid = ?2 WHERE issue_id = ?1") | |
| 466 | .run(pull.id, "0".repeat(40)); | |
| 467 | const leaned = withLikely(ctx(), decisionsFor(ctx(), [repo])); | |
| 468 | const card = leaned.find((d) => d.pull?.id === pull.id); | |
| 469 | expect(card).toBeDefined(); | |
| 470 | expect(card?.likely).toBeUndefined(); | |
| 471 | await pendingLeans(); | |
| 472 | expect(calls).toBe(2); | |
| 473 | }); | |
| 474 | ||
| 475 | test("no decider configured: no lean, ever, and nothing asked", async () => { | |
| 476 | const withoutDecider = createApp({ dataDir, port: 0 }); | |
| 477 | const anon = { | |
| 478 | app: withoutDecider, | |
| 479 | auth: { user: alice, via: "session" }, | |
| 480 | } as never; | |
| 481 | const asks = withLikely(anon, decisionsFor(anon, [repo])); | |
| 482 | const card = asks.find((d) => d.pull?.id === pull.id); | |
| 483 | expect(card).toBeDefined(); | |
| 484 | expect(card?.likely).toBeUndefined(); | |
| 485 | await pendingLeans(); | |
| 486 | expect(calls).toBe(2); | |
| 487 | withoutDecider.close(); | |
| 488 | }); | |
| 489 | }); |
test/studio.test.ts +137
| @@ -16,8 +16,10 @@ import { getPull, type Pull } from "../src/core/pulls.ts"; | ||
| 16 | 16 | import type { Repo } from "../src/core/repos.ts"; |
| 17 | 17 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 18 | 18 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 19 | import type { Decider } from "../src/crew/decide.ts"; | |
| 19 | 20 | import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts"; |
| 20 | 21 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | import { pendingLeans } from "../src/web/pages/studio.ts"; | |
| 21 | 23 | |
| 22 | 24 | describe("the studio", () => { |
| 23 | 25 | let dataDir: string; |
| @@ -369,3 +371,138 @@ describe("the studio", () => { | ||
| 369 | 371 | expect(after).not.toContain('action="/studio/asks"'); |
| 370 | 372 | }); |
| 371 | 373 | }); |
| 374 | ||
| 375 | describe("the studio home does not wait on the decider", () => { | |
| 376 | const DECIDER_MS = 800; | |
| 377 | let dataDir: string; | |
| 378 | let app: App; | |
| 379 | let servers: Servers; | |
| 380 | let base: string; | |
| 381 | let alice: User; | |
| 382 | let repo: Repo; | |
| 383 | let cookie: string; | |
| 384 | let calls: number; | |
| 385 | ||
| 386 | const get = (path: string) => | |
| 387 | fetch(`${base}${path}`, { | |
| 388 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 389 | redirect: "manual", | |
| 390 | }); | |
| 391 | ||
| 392 | beforeAll(async () => { | |
| 393 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-lean-")); | |
| 394 | calls = 0; | |
| 395 | const decider: Decider = { | |
| 396 | name: "slow", | |
| 397 | async decide(_state, questions) { | |
| 398 | calls++; | |
| 399 | await Bun.sleep(DECIDER_MS); | |
| 400 | const options = questions["option"]!.options; | |
| 401 | return { | |
| 402 | option: { | |
| 403 | choice: options[0]!, | |
| 404 | p: 0.9, | |
| 405 | probabilities: Object.fromEntries( | |
| 406 | options.map((o, i) => [o, i === 0 ? 0.9 : 0.05]), | |
| 407 | ), | |
| 408 | }, | |
| 409 | } as never; | |
| 410 | }, | |
| 411 | }; | |
| 412 | app = createApp({ dataDir, port: 0, decider }); | |
| 413 | servers = await serve(app); | |
| 414 | base = servers.url; | |
| 415 | const a = await createUser(app.db, { | |
| 416 | username: "alice", | |
| 417 | email: "[email protected]", | |
| 418 | password: "a-good-passphrase", | |
| 419 | adminIfFirst: true, | |
| 420 | }); | |
| 421 | if (!a.ok) throw new Error(a.error); | |
| 422 | alice = a.value; | |
| 423 | cookie = ( | |
| 424 | ( | |
| 425 | await fetch(`${base}/signin`, { | |
| 426 | method: "POST", | |
| 427 | body: new URLSearchParams({ | |
| 428 | username: "alice", | |
| 429 | password: "a-good-passphrase", | |
| 430 | }), | |
| 431 | redirect: "manual", | |
| 432 | }) | |
| 433 | ).headers.get("set-cookie") ?? "" | |
| 434 | ).split(";")[0]!; | |
| 435 | const made = await createRepo(app, alice, { | |
| 436 | name: "leans", | |
| 437 | visibility: "private", | |
| 438 | description: "for the lean", | |
| 439 | }); | |
| 440 | if (!made.ok) throw new Error(String(made.error)); | |
| 441 | repo = made.value; | |
| 442 | const seeded = await commitToBranch(app, alice, repo, { | |
| 443 | message: "Start", | |
| 444 | files: [ | |
| 445 | { | |
| 446 | path: "package.json", | |
| 447 | content: '{"name":"leans","scripts":{"start":"bun run server.ts"}}\n', | |
| 448 | }, | |
| 449 | { | |
| 450 | path: "server.ts", | |
| 451 | content: | |
| 452 | "Bun.serve({ port: Number(process.env.PORT), fetch: () => new Response('ok') });\n", | |
| 453 | }, | |
| 454 | ], | |
| 455 | }); | |
| 456 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 457 | const enabled = await enableApp(app, alice, repo, { slug: "leans" }); | |
| 458 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 459 | const scout = await createAgent(app, alice, { username: "scout" }); | |
| 460 | if (!scout.ok) throw new Error(String(scout.error)); | |
| 461 | expect(addToCrew(app, alice, repo, "scout").ok).toBe(true); | |
| 462 | const scoutUser = getUserByName(app.db, "scout")!; | |
| 463 | const branch = await commitToBranch(app, alice, repo, { | |
| 464 | branch: "feature/x", | |
| 465 | base: "main", | |
| 466 | message: "Add x", | |
| 467 | files: [{ path: "x.md", content: "x\n" }], | |
| 468 | }); | |
| 469 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 470 | const opened = await openPull(app, scoutUser, repo, { | |
| 471 | title: "Add x", | |
| 472 | body: "", | |
| 473 | baseBranch: "main", | |
| 474 | headBranch: "feature/x", | |
| 475 | }); | |
| 476 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 477 | const pull = opened.value; | |
| 478 | const run = createRun(app.db, { | |
| 479 | repoId: repo.id, | |
| 480 | kind: "check", | |
| 481 | issueId: pull.id, | |
| 482 | env: `pull-${pull.number}`, | |
| 483 | commitOid: pull.headOid, | |
| 484 | reason: "pull", | |
| 485 | }); | |
| 486 | claimRun(app.db, run.id); | |
| 487 | finishRun(app.db, run.id, { state: "ok", summary: "check passed" }); | |
| 488 | }); | |
| 489 | ||
| 490 | afterAll(async () => { | |
| 491 | servers.stop(); | |
| 492 | app.close(); | |
| 493 | await rm(dataDir, { recursive: true, force: true }); | |
| 494 | }); | |
| 495 | ||
| 496 | test("answers well under the decider's own time, with no call made while rendering, and the second render carries the lean the first one asked for", async () => { | |
| 497 | const started = Date.now(); | |
| 498 | const home = await (await get("/")).text(); | |
| 499 | expect(Date.now() - started).toBeLessThan(DECIDER_MS / 2); | |
| 500 | expect(calls).toBe(0); | |
| 501 | expect(home).toContain("Ship"); | |
| 502 | expect(home).not.toContain("by the decider"); | |
| 503 | await pendingLeans(); | |
| 504 | expect(calls).toBe(1); | |
| 505 | const again = await (await get("/")).text(); | |
| 506 | expect(again).toContain("by the decider"); | |
| 507 | }); | |
| 508 | }); |
new above
offline