Public: anyone can read this; the crew can change it.
After a new forge's first ask the home says who took it and what they are doing, or exactly what is missing #11798
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11788-after-a-new-forge-s-first-ask-the-home-s → main 5 commits fork 8 threads · 8 unresolved
docs/features/ask-status.md added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | # What became of the first ask | |
| 2 | ||
| 3 | After a new forge's first ask, home says what became of it, in plain words: | |
| 4 | ||
| 5 | - a builder on it: "atlas is building it", "atlas is ready to start on it"; | |
| 6 | - in line: "Waiting for a builder: 2 ahead of it" (the queued agent runs on the person's apps made before this one) or "Next in line for a builder"; "Waiting for a builder" only while no run exists; | |
| 7 | - ended: "atlas stopped on it: the run failed", "atlas's run on it was cancelled", "atlas finished a run on it and opened no pull"; | |
| 8 | - missing: "No AI account and no included credit left: add a key". | |
| 9 | ||
| 10 | The ask is the person's oldest issue on their apps (the spawned forge's and their own; an app's direction is no ask), so a later issue never replaces it. The status ends with the first ask: once it is closed or its builder opened a pull. The first ask's flash names the app and the issue ("bakery, issue #1: …"). | |
| 11 | ||
| 12 | - **Web:** a row at the top of the card on `/`; a missing key shows one quiet link (admins: "Add a model key", others: "See the plans"); the primary button stays on the step. While the ask is building, its app's crew row is left out, so the builder is named once. | |
| 13 | - **Terminal:** the first line of the client's home in `forge tui` (`src/tui/screens/dashboard.ts`), with the action's path. | |
| 14 | - Both read `askStatus()` in `src/core/ask-status.ts`. | |
| 15 | - Try it: spawn a forge, claim it, ask for something on `/`, open `/` or run the terminal. | |
| 16 | - Tests: `test/next-steps-ask-status.test.ts`, `test/first-ask-flash.test.ts`. |
src/core/ask-status.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | /** | |
| 2 | * What became of a new forge's first ask (#11788): who has it, or exactly what | |
| 3 | * is missing, or that it waits. One reading for the home card and the | |
| 4 | * terminal home, so both say the same thing. Plain data in, plain data out. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { listReposOwnedBy, type Repo } from "./repos.ts"; | |
| 10 | import { spawnedRepos } from "./next-steps.ts"; | |
| 11 | import { getUserById, type User } from "./users.ts"; | |
| 12 | ||
| 13 | /** What became of the ask, in one word, so a page can tell one state from another without reading the words. */ | |
| 14 | export type AskState = "building" | "missing" | "stopped" | "cancelled" | "finished" | "queued" | "ready" | "waiting"; | |
| 15 | ||
| 16 | /** The words, the app and issue they name, and the one action when something is missing. */ | |
| 17 | export interface AskStatus { | |
| 18 | readonly state: AskState; | |
| 19 | readonly says: string; | |
| 20 | readonly repoId: string; | |
| 21 | readonly app: string; | |
| 22 | readonly issueNumber: number; | |
| 23 | readonly href: string; | |
| 24 | readonly action: { readonly href: string; readonly label: string } | null; | |
| 25 | } | |
| 26 | ||
| 27 | interface RunAt { | |
| 28 | readonly id: string; | |
| 29 | readonly state: string; | |
| 30 | readonly branch: string; | |
| 31 | readonly actor_id: string | null; | |
| 32 | readonly created_at: string; | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 36 | * The first ask `askerId` made on `repos` (their oldest issue there, open or | |
| 37 | * not, so a later issue never replaces it; an app's direction is no ask) and | |
| 38 | * what became of it. `canRun`: the crew has a | |
| 39 | * key or credit. Null when nothing was asked, or once the first ask is over: | |
| 40 | * closed, or its builder opened a pull (the pull says the rest). | |
| 41 | */ | |
| 42 | export function askStatus( | |
| 43 | db: Database, | |
| 44 | repos: readonly Repo[], | |
| 45 | askerId: string, | |
| 46 | canRun: boolean, | |
| 47 | admin: boolean, | |
outdated
Box 1 asks for the queue position. Look up this run's real place (queued agent runs created before it) instead of dropping it. |
||
| 48 | ): AskStatus | null { | |
| 49 | if (repos.length === 0) return null; | |
| 50 | const marks = repos.map(() => "?").join(","); | |
| 51 | const ids = repos.map((r) => r.id); | |
| 52 | const found = db | |
| 53 | .query<{ id: string; repo_id: string; number: number; state: string; assignee_id: string | null }, string[]>( | |
| 54 | `SELECT id, repo_id, number, state, assignee_id FROM issue | |
| 55 | WHERE kind = 'issue' AND author_id = ? AND repo_id IN (${marks}) | |
| 56 | AND id NOT IN (SELECT direction_issue_id FROM repo WHERE direction_issue_id IS NOT NULL) | |
| 57 | ORDER BY created_at ASC, number ASC LIMIT 1`, | |
| 58 | ) | |
| 59 | .get(askerId, ...ids); | |
| 60 | const repo = repos.find((r) => r.id === found?.repo_id); | |
| 61 | if (!found || !repo || found.state !== "open") return null; | |
| 62 | const run = db | |
| 63 | .query<RunAt, [string]>( | |
| 64 | `SELECT id, state, branch, actor_id, created_at FROM run | |
| 65 | WHERE issue_id = ?1 AND kind = 'agent' ORDER BY created_at DESC, id DESC LIMIT 1`, | |
| 66 | ) | |
| 67 | .get(found.id); | |
| 68 | if (run && pulled(db, repo.id, found.id)) return null; | |
| 69 | const named = { repoId: repo.id, app: repo.name, issueNumber: found.number, href: `/${repo.ownerName}/${repo.name}/issues/${found.number}` }; | |
| 70 | const say = (state: AskState, says: string): AskStatus => ({ ...named, state, says, action: null }); | |
| 71 | const whoId = found.assignee_id ?? run?.actor_id ?? null; | |
| 72 | const who = (whoId === null ? null : getUserById(db, whoId)?.username) ?? "The builder"; | |
| 73 | if (run?.state === "running") return say("building", `${who} is building it`); | |
| 74 | if (!canRun) | |
| 75 | return { | |
| 76 | ...say("missing", "No AI account and no included credit left: add a key"), | |
| 77 | action: admin ? { href: "/settings/subscriptions", label: "Add a model key" } : { href: "/pricing", label: "See the plans" }, | |
| 78 | }; | |
| 79 | if (run?.state === "failed") return say("stopped", `${who} stopped on it: the run failed`); | |
| 80 | if (run?.state === "cancelled") return say("cancelled", `${who}'s run on it was cancelled`); | |
| 81 | if (run?.state === "ok") return say("finished", `${who} finished a run on it and opened no pull`); | |
| 82 | if (run?.state === "queued") { | |
| 83 | const ahead = queuedAhead(db, ids, run); | |
| 84 | return say("queued", ahead === 0 ? "Next in line for a builder" : `Waiting for a builder: ${ahead} ahead of it`); | |
| 85 | } | |
| 86 | if (found.assignee_id !== null) return say("ready", `${who} is ready to start on it`); | |
| 87 | return say("waiting", "Waiting for a builder"); | |
| 88 | } | |
| 89 | ||
| 90 | /** Where a person's asks land: the spawned forge's apps and the apps they own (a first ask makes one of their own). */ | |
| 91 | export async function askRepos(app: App, user: User): Promise<readonly Repo[]> { | |
| 92 | const spawned = await spawnedRepos(app, user); | |
| 93 | const seen = new Set(spawned.map((r) => r.id)); | |
| 94 | return [...spawned, ...listReposOwnedBy(app.db, user.id).filter((r) => !seen.has(r.id))]; | |
| 95 | } | |
| 96 | ||
| 97 | /** Whether any agent run on the issue pushed a branch a pull was opened from. */ | |
| 98 | function pulled(db: Database, repoId: string, issueId: string): boolean { | |
| 99 | return ( | |
| 100 | db | |
| 101 | .query<{ one: number }, [string, string]>( | |
| 102 | `SELECT 1 AS one FROM run r JOIN pull p ON r.branch != '' AND p.head_branch = r.branch | |
| 103 | JOIN issue i ON i.id = p.issue_id AND i.repo_id = ?1 | |
| 104 | WHERE r.issue_id = ?2 AND r.kind = 'agent' LIMIT 1`, | |
| 105 | ) | |
| 106 | .get(repoId, issueId) !== null | |
| 107 | ); | |
| 108 | } | |
| 109 | ||
| 110 | /** The queued agent runs on `repoIds` made before `run`: its real place in line. */ | |
| 111 | function queuedAhead(db: Database, repoIds: readonly string[], run: RunAt): number { | |
| 112 | return ( | |
| 113 | db | |
| 114 | .query<{ n: number }, string[]>( | |
| 115 | `SELECT COUNT(*) AS n FROM run | |
| 116 | WHERE kind = 'agent' AND state = 'queued' AND id != ? AND repo_id IN (${repoIds.map(() => "?").join(",")}) | |
| 117 | AND (created_at < ? OR (created_at = ? AND id < ?))`, | |
| 118 | ) | |
| 119 | .get(run.id, ...repoIds, run.created_at, run.created_at, run.id)?.n ?? 0 | |
| 120 | ); | |
| 121 | } | |
| 122 | ||
| 123 | /** The terminal's one line: the words, what they name, and the way forward when something is missing. */ | |
| 124 | export function askStatusLine(s: AskStatus): string { | |
| 125 | return `${s.says} (${s.app}, issue #${s.issueNumber})${s.action ? `. ${s.action.label} at ${s.action.href}` : ""}`; | |
| 126 | } |
src/tui/screens/dashboard.ts +5 −1
| @@ -26,9 +26,10 @@ import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_ | ||
| 26 | 26 | import { clientAppRows, clientDoings, clientFinished, doingLine, finishedLine } from "../../core/client-apps.ts"; |
| 27 | 27 | import { appCrew } from "../../core/app-crew.ts"; |
| 28 | 28 | import { nextStepsFor } from "../../core/next-steps.ts"; |
| 29 | import { askRepos, askStatus, askStatusLine } from "../../core/ask-status.ts"; | |
| 29 | 30 | import { keepThought } from "../../actions/thought.ts"; |
| 30 | 31 | import { SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts"; |
| 31 | import { crewFirstText } from "../../web/studio/crew_key.ts"; | |
| 32 | import { crewCanRun, crewFirstText } from "../../web/studio/crew_key.ts"; | |
| 32 | 33 | import { agentsLine } from "../../web/studio/client_home.ts"; |
| 33 | 34 | import { lastVisit } from "../../core/home_visit.ts"; |
| 34 | 35 | import { sinceDigest } from "../../web/studio/since.ts"; |
| @@ -357,6 +358,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 357 | 358 | const crewOf = new Map(appCrew(f.app.db, apps.map((a) => a.repo)).map((c) => [c.repo.id, c] as const)); |
| 358 | 359 | const doings = clientDoings(f.app.db, ids); |
| 359 | 360 | const finished = clientFinished(f.app.db, ids); |
| 361 | const mine = await askRepos(f.app, f.user); | |
| 362 | const asked = askStatus(f.app.db, mine, f.user.id, crewCanRun(f.app), f.user.isAdmin === true); | |
| 360 | 363 | clientLines = |
| 361 | 364 | apps.length === 0 |
| 362 | 365 | ? ["Your apps will appear here."] |
| @@ -372,6 +375,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 372 | 375 | const crew = crewOf.get(a.repo.id)!.says; |
| 373 | 376 | return `${line}${doing === "" ? "" : ` ${doing}`} ${crew[0]!.toUpperCase()}${crew.slice(1)}.`; |
| 374 | 377 | }); |
| 378 | if (asked) clientLines = [askStatusLine(asked), ...clientLines]; | |
| 375 | 379 | ctx.invalidate(); |
| 376 | 380 | }; |
| 377 | 381 |
src/web/pages/next-steps.ts +19 −2
| @@ -25,7 +25,8 @@ import { icon } from "../icons.ts"; | ||
| 25 | 25 | import { badge, btn, row } from "../ui.ts"; |
| 26 | 26 | import { everydayAsk } from "./everyday.ts"; |
| 27 | 27 | import { EVERYDAY_WHY } from "../../core/everyday.ts"; |
| 28 | import { crewFirstLine } from "../studio/crew_key.ts"; | |
| 28 | import { askRepos, askStatus, type AskStatus } from "../../core/ask-status.ts"; | |
| 29 | import { crewCanRun, crewFirstLine } from "../studio/crew_key.ts"; | |
| 29 | 30 | import { NEW_APP_TARGET } from "../studio/feed.ts"; |
| 30 | 31 | import { COMPOSER_FIELDS, TELL_MAX } from "../share_target.ts"; |
| 31 | 32 | |
| @@ -41,6 +42,19 @@ export function crewAtWork(ctx: Ctx, repos: Awaited<ReturnType<typeof spawnedRep | ||
| 41 | 42 | }); |
| 42 | 43 | } |
| 43 | 44 | |
| 45 | /** The apps whose crew row shows beside the ask's: a builder at work is said once, so while the ask is building, its app's row goes. */ | |
| 46 | export function besideAsk<R extends { readonly id: string }>(repos: readonly R[], ask: AskStatus | null): R[] { | |
| 47 | return ask?.state === "building" ? repos.filter((r) => r.id !== ask.repoId) : [...repos]; | |
| 48 | } | |
| 49 | ||
| 50 | /** The status block; its action is a quiet link so the step keeps the one primary. */ | |
| 51 | export function askBlock(s: AskStatus): Html { | |
| 52 | return html`<div class="step-body" data-ask-status> | |
| 53 | <ul class="list">${row({ href: s.href, title: html`${s.says}`, meta: html`${s.app} · issue #${String(s.issueNumber)}` })}</ul> | |
| 54 | ${s.action ? html`<p>${btn(s.action.label, { href: s.action.href, size: "sm", variant: "link" })}</p>` : null} | |
| 55 | </div>`; | |
| 56 | } | |
| 57 | ||
| 44 | 58 | /** One step: its row (title, "Done" trail; the primary one in bold), then its |
| 45 | 59 | * body as a block of its own under the title. `.row-meta` is one clipped line, |
outdated
Newest open issue in any spawned repo is not the first ask. A later issue replaces the status. Track the first ask, or the one just made. |
||
| 46 | 60 | * no place for a form. */ |
| @@ -100,6 +114,8 @@ export async function nextStepsCard( | ||
| 100 | 114 | const crew = crewAtWork(ctx, repos); |
| 101 | 115 | if (steps.some((s) => s.key === "tell" && !s.done)) return firstTell(ctx, user, crew); |
| 102 | 116 | const primary = steps.find((s) => !s.done)?.key; |
| 117 | const ask = askStatus(ctx.app.db, await askRepos(ctx.app, user), user.id, crewCanRun(ctx.app), user.isAdmin === true); | |
| 118 | const others = ask?.state === "building" ? crewAtWork(ctx, besideAsk(repos, ask)) : crew; | |
| 103 | 119 | const bodies: Record<NextStep["key"], Html | null> = { |
| 104 | 120 | everyday: html`<p class="muted small">${EVERYDAY_WHY}</p> |
| 105 | 121 | ${everydayAsk(ctx, user)}`, |
| @@ -129,7 +145,8 @@ export async function nextStepsCard( | ||
| 129 | 145 | }; |
| 130 | 146 | return html`<section id="next-steps"> |
| 131 | 147 | <h2>Your next steps</h2> |
| 132 | ${crew.length > 0 ? html`<ul class="list">${crew}</ul>` : null} | |
| 148 | ${ask ? askBlock(ask) : null} | |
| 149 | ${others.length > 0 ? html`<ul class="list">${others}</ul>` : null} | |
| 133 | 150 | ${steps.map((s) => stepBlock(s, s.key === primary, bodies[s.key]))} |
| 134 | 151 | </section>`; |
| 135 | 152 | } |
src/web/studio/actions.ts +27 −20
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * page that sent them, or JSON when the asks panel asked in place. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import type { Routed } from "../../crew/route.ts"; | |
| 11 | 12 | import { COMPOSER_FIELDS, claimShare, releaseShare } from "../share_target.ts"; |
| 12 | 13 | import { mayView } from "../../core/sealed.ts"; |
| 13 | 14 | import { holdPull, releasePull } from "../../actions/holds.ts"; |
| @@ -87,7 +88,9 @@ function toldAnswer( | ||
| 87 | 88 | told: Awaited<ReturnType<typeof tell>>, |
| 88 | 89 | onTold: () => void, |
| 89 | 90 | note = "", |
| 91 | subject = "", | |
| 90 | 92 | ): Promise<Response> | Response { |
| 93 | const named = (message: string): string => prefixed(message, subject); | |
| 91 | 94 | if (!told.ok && told.error === "body_too_long") |
| 92 | 95 | return flashOrJson(ctx, back, { |
| 93 | 96 | tone: "danger", |
| @@ -111,26 +114,28 @@ function toldAnswer( | ||
| 111 | 114 | // The tell's own page, for a caller that asked for JSON (the palette's row). |
| 112 | 115 | const at = { href: `${repoBase(repo)}/issues/${told.value.issue.number}` }; |
| 113 | 116 | // "Build" never silently does nothing: with no credit the person is told, with the way forward. |
| 114 | if (!crewCanRun(ctx.app)) return flashOrJson(ctx, back, noCreditFlash(ctx), at); | |
| 115 | if (note !== "") return flashOrJson(ctx, back, { tone: "attn", message: note }, at); | |
| 117 | if (!crewCanRun(ctx.app)) return flashOrJson(ctx, back, withSubject(noCreditFlash(ctx), subject), at); | |
outdated
The inline destructuring lambda is unreadable. Name a small helper that prefixes the flash message. |
||
| 118 | if (note !== "") return flashOrJson(ctx, back, { tone: "attn", message: named(note) }, at); | |
| 119 | return flashOrJson(ctx, back, { message: tellFlashWords(routed, from, subject) }, at); | |
| 120 | } | |
| 121 | ||
| 122 | /** The words for a tell that went: who is on it, or that nobody is on the crew yet; named by the subject. */ | |
| 123 | export function tellFlashWords(routed: Routed | undefined, from: string | null, subject: string): string { | |
| 116 | 124 | if (routed === undefined) |
| 117 | return flashOrJson( | |
| 118 | ctx, | |
| 119 | back, | |
| 120 | { | |
| 121 | message: `Opened as an issue. Nobody is on the crew to take it yet — add someone under Settings → Crew.`, | |
| 122 | }, | |
| 123 | at, | |
| 124 | ); | |
| 125 | const builder = routed.builder; | |
| 126 | return flashOrJson( | |
| 127 | ctx, | |
| 128 | back, | |
| 129 | { | |
| 130 | message: `${builder.user.username} is on it — ${routed.size} ${routed.kind}${routed.by === "rule" ? "" : `, ${routed.by} decided in ${routed.ms} ms`}.${from === null ? " Watch it work below." : ""}`, | |
| 131 | }, | |
| 132 | at, | |
| 133 | ); | |
| 125 | return prefixed("Opened as an issue. Nobody is on the crew to take it yet — add someone under Settings → Crew.", subject); | |
| 126 | const decided = routed.by === "rule" ? "" : `, ${routed.by} decided in ${routed.ms} ms`; | |
| 127 | const watch = from === null ? " Watch it work below." : ""; | |
| 128 | return prefixed(`${routed.builder.user.username} is on it — ${routed.size} ${routed.kind}${decided}.${watch}`, subject); | |
| 129 | } | |
| 130 | ||
| 131 | /** The first ask's flash names the app and the issue it made: "<subject>: <message>"; no subject, the message as is. */ | |
| 132 | export function prefixed(message: string, subject: string): string { | |
| 133 | return subject === "" ? message : `${subject}: ${message}`; | |
| 134 | } | |
| 135 | ||
| 136 | /** A flash with its message prefixed by the subject. */ | |
| 137 | function withSubject<T extends { message: string }>(flash: T, subject: string): T { | |
| 138 | return { ...flash, message: prefixed(flash.message, subject) }; | |
| 134 | 139 | } |
| 135 | 140 | |
| 136 | 141 | /** The flash for words kept while the crew has no credit: saved, the crew cannot build yet, and the way forward. */ |
| @@ -240,7 +245,9 @@ async function composerTell(ctx: Ctx, form: Form, target: string, home: string, | ||
| 240 | 245 | const first = await firstAsk(ctx.app, ctx.auth.user, field(form, COMPOSER_FIELDS.text), crewCanRun(ctx.app), from); |
| 241 | 246 | if (!first.ok) return flashOrJson(ctx, home, { tone: "danger", message: first.error.words }); |
outdated
|
||
| 242 | 247 | const { repo, told, autonomyFailed } = first.value; |
| 243 | return toldAnswer(ctx, repo, form, repoBase(repo), from, told, onTold, autonomyFailed ? AUTONOMY_FAILED_WORDS : ""); | |
| 248 | const note = autonomyFailed ? AUTONOMY_FAILED_WORDS : ""; | |
| 249 | const subject = told.ok ? `${repo.name}, issue #${told.value.issue.number}` : ""; | |
| 250 | return toldAnswer(ctx, repo, form, repoBase(repo), from, told, onTold, note, subject); | |
| 244 | 251 | } |
| 245 | 252 | // "the forge" is no app: the words are kept as a thought, exactly as typed. |
| 246 | 253 | if (target === FORGE_TARGET) { |
test/first-ask-flash.test.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | /** | |
| 2 | * The first ask's flash names the app and the issue it made, in each state | |
| 3 | * (#11788): a builder took it, nobody is on the crew, no credit; and the | |
| 4 | * terminal's home opens on the same status line as the card. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { listReposOwnedBy } from "../src/core/repos.ts"; | |
| 8 | import { getUserByName } from "../src/core/users.ts"; | |
| 9 | import { fundingOf, keepFunding } from "../src/spawn/funding.ts"; | |
| 10 | import { prefixed, tellFlashWords } from "../src/web/studio/actions.ts"; | |
| 11 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 12 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 13 | import { settleApp } from "./helpers/tui.ts"; | |
| 14 | import { claimedForge, type ClaimedForge } from "./helpers/claimed-forge.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let forge: ClaimedForge; | |
| 20 | beforeAll(async () => { | |
| 21 | forge = await claimedForge("forge-first-flash-"); | |
| 22 | }, 60_000); | |
| 23 | afterAll(() => forge.stop()); | |
| 24 | ||
| 25 | const flashOf = (res: Response) => decodeURIComponent(res.headers.getSetCookie().join(";")).replace(/\+/g, " "); | |
| 26 | ||
| 27 | test("prefixed names the subject, or leaves the message alone", () => { | |
| 28 | expect(prefixed("atlas is on it", "menu, issue #1")).toBe("menu, issue #1: atlas is on it"); | |
| 29 | expect(prefixed("atlas is on it", "")).toBe("atlas is on it"); | |
| 30 | }); | |
| 31 | ||
| 32 | test("nobody on the crew: the flash names the app and issue, and says to add someone", () => { | |
| 33 | expect(tellFlashWords(undefined, null, "menu, issue #1")).toBe( | |
| 34 | "menu, issue #1: Opened as an issue. Nobody is on the crew to take it yet — add someone under Settings → Crew.", | |
| 35 | ); | |
| 36 | }); | |
| 37 | ||
| 38 | test("with credit the flash names the app and the issue", async () => { | |
| 39 | const res = await forge.post(forge.owner, "/tell", { app: "new-app", text: "A weekly menu page for the bakery" }); | |
| 40 | const maria = getUserByName(forge.child.db, "maria")!; | |
| 41 | const repo = listReposOwnedBy(forge.child.db, maria.id).find((r) => r.name.includes("menu"))!; | |
| 42 | expect(flashOf(res)).toContain(`${repo.name}, issue #1: `); | |
| 43 | }); | |
| 44 | ||
| 45 | test("with no credit the flash still names them, and keeps the way forward", async () => { | |
| 46 | const dir = forge.child.config.dataDir; | |
| 47 | const funding = fundingOf(dir)!; | |
| 48 | keepFunding(dir, { token: funding.token, capUsd: 2, spentUsd: 2 }); | |
| 49 | try { | |
| 50 | const res = await forge.post(forge.owner, "/tell", { app: "new-app", text: "A parking map for the market" }); | |
| 51 | const maria = getUserByName(forge.child.db, "maria")!; | |
| 52 | const repo = listReposOwnedBy(forge.child.db, maria.id).find((r) => r.name.includes("parking"))!; | |
| 53 | const flash = flashOf(res); | |
| 54 | expect(flash).toContain(`${repo.name}, issue #1: Saved, but`); | |
| 55 | const home = await (await fetch(`${forge.base}/`, { headers: { cookie: forge.owner } })).text(); | |
| 56 | expect(home).toContain("data-ask-status"); | |
| 57 | expect(home).toContain("No AI account and no included credit left"); | |
| 58 | const card = home.slice(home.indexOf('id="next-steps"')); | |
| 59 | const status = card.slice(card.indexOf("data-ask-status"), card.indexOf("data-step=")); | |
| 60 | expect(status).toContain("Add a model key"); | |
| 61 | expect(status).not.toContain('data-variant="primary"'); | |
| 62 | } finally { | |
| 63 | keepFunding(dir, funding); | |
| 64 | } | |
| 65 | }); | |
| 66 | ||
| 67 | test("the terminal's home opens on the first ask's status line", async () => { | |
| 68 | const maria = getUserByName(forge.child.db, "maria")!; | |
| 69 | const repo = listReposOwnedBy(forge.child.db, maria.id).find((r) => r.name.includes("menu"))!; | |
| 70 | const terminal = headlessTerminal(200, 40); | |
| 71 | const { app } = createForgeApp( | |
| 72 | forge.child, | |
| 73 | { sshUsername: maria.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: maria }, | |
| 74 | { terminal, exitHooks: false }, | |
| 75 | ); | |
| 76 | app.start(); | |
| 77 | await settleApp(app); | |
| 78 | const text = app.screen.toText(); | |
| 79 | app.stop(); | |
| 80 | expect(text).toContain(`(${repo.name}, issue #1)`); | |
| 81 | }); |
test/next-steps-ask-status.test.ts added +183
| @@ -0,0 +1,183 @@ | ||
| 1 | /** | |
| 2 | * After a new forge's first ask the card says who took it and what they do, | |
| 3 | * or exactly what is missing, or where it waits (#11788). | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 11 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 12 | import { createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { claimRun, createRun } from "../src/core/runs.ts"; | |
| 14 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 15 | import { askStatus, askStatusLine } from "../src/core/ask-status.ts"; | |
| 16 | import { askBlock, besideAsk } from "../src/web/pages/next-steps.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | setDefaultTimeout(30_000); | |
| 21 | ||
| 22 | let dir: string; | |
| 23 | let app: App; | |
| 24 | const KEY = "ANTHROPIC_API_KEY"; | |
| 25 | const saved = process.env[KEY]; | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | delete process.env[KEY]; | |
| 29 | dir = await mkdtemp(join(tmpdir(), "forge-ask-status-")); | |
| 30 | app = createApp({ dataDir: dir, port: 0 }); | |
| 31 | const r = await createUser(app.db, { origin: "seed", trusted: true, username: "maria", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 32 | if (!r.ok) throw new Error("user"); | |
| 33 | if (!(await createAgent(app, r.value, { username: "atlas" })).ok) throw new Error("agent"); | |
| 34 | }); | |
| 35 | afterAll(async () => { | |
| 36 | if (saved === undefined) delete process.env[KEY]; | |
| 37 | else process.env[KEY] = saved; | |
| 38 | app.close(); | |
| 39 | await rm(dir, { recursive: true, force: true }); | |
| 40 | }); | |
| 41 | ||
| 42 | let n = 0; | |
| 43 | /** A fresh app with one open ask; `build` may assign the builder and start a run. */ | |
| 44 | async function asked(build?: "running" | "queued") { | |
| 45 | const maria = getUserByName(app.db, "maria")!; | |
| 46 | const atlas = getUserByName(app.db, "atlas")!; | |
| 47 | const made = await createRepo(app, maria, { name: `menu${++n}`, visibility: "private" }); | |
| 48 | if (!made.ok) throw new Error("repo"); | |
| 49 | const issue = openIssue(app, maria, made.value, { title: "A weekly menu page" }); | |
| 50 | if (!issue.ok) throw new Error("issue"); | |
| 51 | if (build) addToCrew(app, maria, made.value, "atlas"); | |
| 52 | if (build) assignIssue(app, maria, made.value, issue.value, atlas); | |
| 53 | if (build === "running" || build === "queued") { | |
| 54 | // Assigning may already have queued the builder's run; if not, queue one. | |
| 55 | const queued = app.db.query<{ id: string }, [string]>("SELECT id FROM run WHERE issue_id = ?1 AND kind = 'agent' ORDER BY id DESC LIMIT 1").get(issue.value.id); | |
| 56 | const run = queued ?? createRun(app.db, { repoId: made.value.id, kind: "agent", issueId: issue.value.id, actorId: atlas.id, reason: "assigned" }); | |
| 57 | if (build === "running") claimRun(app.db, run.id); | |
| 58 | } | |
| 59 | return { repo: made.value, issue: issue.value }; | |
| 60 | } | |
| 61 | const status = (repo: Awaited<ReturnType<typeof asked>>["repo"], admin = true, canRun = !!process.env[KEY]) => | |
| 62 | askStatus(app.db, [repo], getUserByName(app.db, "maria")!.id, canRun, admin); | |
| 63 | ||
| 64 | test("a builder on it: their name and that they are building it, naming the app and issue", async () => { | |
| 65 | process.env[KEY] = "sk-test"; | |
| 66 | const a = await asked("running"); | |
| 67 | const s = status(a.repo)!; | |
| 68 | expect(s).toMatchObject({ state: "building", says: "atlas is building it" }); | |
| 69 | expect(s.app).toBe(a.repo.name); | |
| 70 | expect(s.issueNumber).toBe(a.issue.number); | |
| 71 | expect(s.href).toBe(`/${a.repo.ownerName}/${a.repo.name}/issues/${a.issue.number}`); | |
| 72 | }); | |
| 73 | ||
| 74 | test("nothing to run on: the one thing missing, in plain words, with its action", async () => { | |
| 75 | delete process.env[KEY]; | |
| 76 | const a = await asked(); | |
| 77 | expect(status(a.repo)).toMatchObject({ says: "No AI account and no included credit left: add a key", action: { href: "/settings/subscriptions" } }); | |
| 78 | expect(status(a.repo, false)?.action?.href).toBe("/pricing"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("no run yet: waiting for a builder, with no number", async () => { | |
| 82 | process.env[KEY] = "sk-test"; | |
| 83 | const waiting = status((await asked()).repo)!; | |
| 84 | expect(waiting).toMatchObject({ state: "waiting", says: "Waiting for a builder" }); | |
| 85 | expect(waiting.says).not.toMatch(/\d/); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a queued run: its real place among the queued runs made before it on the person's apps", async () => { | |
| 89 | process.env[KEY] = "sk-test"; | |
| 90 | const alone = await asked("queued"); | |
| 91 | expect(status(alone.repo)).toMatchObject({ state: "queued", says: "Next in line for a builder" }); | |
| 92 | // Two runs queued earlier on the person's other app, one later: two ahead, the later one not counted. | |
| 93 | const a = await asked(); | |
| 94 | const other = await asked(); | |
| 95 | const atlas = getUserByName(app.db, "atlas")!; | |
| 96 | const queue = (repo: typeof a.repo, issueId: string, at: string) => { | |
| 97 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId, actorId: atlas.id, reason: "assigned" }); | |
| 98 | app.db.query("UPDATE run SET created_at = ?2 WHERE id = ?1").run(run.id, at); | |
| 99 | }; | |
| 100 | queue(other.repo, other.issue.id, "2000-01-01T00:00:01.000Z"); | |
| 101 | queue(other.repo, other.issue.id, "2000-01-01T00:00:02.000Z"); | |
| 102 | queue(a.repo, a.issue.id, "2000-01-01T00:00:03.000Z"); | |
| 103 | queue(other.repo, other.issue.id, "2000-01-01T00:00:04.000Z"); | |
| 104 | app.db.query("UPDATE issue SET created_at = 0 WHERE id = ?1").run(a.issue.id); | |
| 105 | expect(askStatus(app.db, [a.repo, other.repo], getUserByName(app.db, "maria")!.id, true, true)).toMatchObject({ state: "queued", says: "Waiting for a builder: 2 ahead of it", issueNumber: a.issue.number }); | |
| 106 | // Scoped to the person's apps: the same ask, looked at alone, is next. | |
| 107 | expect(status(a.repo)!.says).toBe("Next in line for a builder"); | |
| 108 | }); | |
| 109 | ||
| 110 | /** An ask whose builder's run ended in `state`. */ | |
| 111 | async function ended(state: "failed" | "cancelled" | "ok") { | |
| 112 | const a = await asked("running"); | |
| 113 | app.db.query("UPDATE run SET state = ?2, branch = ?3 WHERE issue_id = ?1 AND kind = 'agent'").run(a.issue.id, state, `atlas/${a.issue.number}-${state}`); | |
| 114 | return a; | |
| 115 | } | |
| 116 | ||
| 117 | test("a run that ended without a pull says how, in plain words, never that it waits", async () => { | |
| 118 | process.env[KEY] = "sk-test"; | |
| 119 | expect(status((await ended("failed")).repo)).toMatchObject({ state: "stopped", says: "atlas stopped on it: the run failed" }); | |
| 120 | expect(status((await ended("cancelled")).repo)).toMatchObject({ state: "cancelled", says: "atlas's run on it was cancelled" }); | |
| 121 | expect(status((await ended("ok")).repo)).toMatchObject({ state: "finished", says: "atlas finished a run on it and opened no pull" }); | |
| 122 | }); | |
| 123 | ||
| 124 | test("the status ends with the first ask: once it has a pull, or is closed, or a later ask is all that is open", async () => { | |
| 125 | process.env[KEY] = "sk-test"; | |
| 126 | const a = await ended("ok"); | |
| 127 | const maria = getUserByName(app.db, "maria")!; | |
| 128 | const pr = openIssue(app, maria, a.repo, { title: "The weekly menu page" }); | |
| 129 | if (!pr.ok) throw new Error("issue"); | |
| 130 | app.db.query("UPDATE issue SET kind = 'pull' WHERE id = ?1").run(pr.value.id); | |
| 131 | app.db | |
| 132 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, '', '', ?3)") | |
| 133 | .run(pr.value.id, `atlas/${a.issue.number}-ok`, a.repo.id); | |
| 134 | expect(status(a.repo)).toBeNull(); | |
| 135 | ||
| 136 | const b = await asked(); | |
| 137 | const later = openIssue(app, maria, b.repo, { title: "Another thing" }); | |
| 138 | if (!later.ok) throw new Error("issue"); | |
| 139 | app.db.query("UPDATE issue SET created_at = created_at + 5000 WHERE id = ?1").run(later.value.id); | |
| 140 | app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(b.issue.id); | |
| 141 | expect(status(b.repo)).toBeNull(); | |
| 142 | }); | |
| 143 | ||
| 144 | test("the first ask is the oldest open one: a later issue does not replace it", async () => { | |
| 145 | process.env[KEY] = "sk-test"; | |
| 146 | const a = await asked(); | |
| 147 | const maria = getUserByName(app.db, "maria")!; | |
| 148 | const later = openIssue(app, maria, a.repo, { title: "Another thing" }); | |
| 149 | if (!later.ok) throw new Error("issue"); | |
| 150 | app.db.query("UPDATE issue SET created_at = created_at + 5000 WHERE id = ?1").run(later.value.id); | |
| 151 | expect(status(a.repo)!.issueNumber).toBe(a.issue.number); | |
| 152 | }); | |
| 153 | ||
| 154 | test("the card's missing-key action is one quiet link, never a second primary, for an admin and for a person", async () => { | |
| 155 | delete process.env[KEY]; | |
| 156 | const a = await asked(); | |
| 157 | const admin = String(askBlock(status(a.repo, true)!)); | |
| 158 | expect(admin).toContain('href="/settings/subscriptions"'); | |
| 159 | expect(admin).toContain("Add a model key"); | |
| 160 | expect(admin).not.toContain('data-variant="primary"'); | |
| 161 | const person = String(askBlock(status(a.repo, false)!)); | |
| 162 | expect(person).toContain('href="/pricing"'); | |
| 163 | expect(person).toContain("See the plans"); | |
| 164 | expect(person).not.toContain("/settings/subscriptions"); | |
| 165 | expect(person).not.toContain('data-variant="primary"'); | |
| 166 | }); | |
| 167 | ||
| 168 | test("the terminal's line says the same words, the app and issue, and the action", async () => { | |
| 169 | delete process.env[KEY]; | |
| 170 | const a = await asked(); | |
| 171 | const line = askStatusLine(status(a.repo)!); | |
| 172 | expect(line).toBe(`No AI account and no included credit left: add a key (${a.repo.name}, issue #${a.issue.number}). Add a model key at /settings/subscriptions`); | |
| 173 | }); | |
| 174 | ||
| 175 | test("a builder at work is said once: while the ask is building, its app's crew row goes; otherwise it stays", async () => { | |
| 176 | process.env[KEY] = "sk-test"; | |
| 177 | const building = await asked("running"); | |
| 178 | const other = await asked(); | |
| 179 | const repos = [building.repo, other.repo]; | |
| 180 | expect(besideAsk(repos, status(building.repo)).map((r) => r.id)).toEqual([other.repo.id]); | |
| 181 | expect(besideAsk(repos, status(other.repo)).map((r) => r.id)).toEqual(repos.map((r) => r.id)); | |
| 182 | expect(besideAsk(repos, null)).toHaveLength(2); | |
| 183 | }); |
Any run state other than running (failed, cancelled, done) lands on 'Waiting for a builder' below. Name each in plain words and test.