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

Merged opened this

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

Merged

scout/forge:scout/11788-after-a-new-forge-s-first-ask-the-home-s → main 5 commits fork 8 threads · 8 unresolved

  • Merged. scout/11788-after-a-new-forge-s-first-ask-the-home-s was merged into main as b538a9e.

Review

Sign in to review this change.
7 files changed +457 −23
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,
outdated

Any run state other than running (failed, cancelled, done) lands on 'Waiting for a builder' below. Name each in plain words and test.

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_
2626 import { clientAppRows, clientDoings, clientFinished, doingLine, finishedLine } from "../../core/client-apps.ts";
2727 import { appCrew } from "../../core/app-crew.ts";
2828 import { nextStepsFor } from "../../core/next-steps.ts";
29 import { askRepos, askStatus, askStatusLine } from "../../core/ask-status.ts";
2930 import { keepThought } from "../../actions/thought.ts";
3031 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";
3233 import { agentsLine } from "../../web/studio/client_home.ts";
3334 import { lastVisit } from "../../core/home_visit.ts";
3435 import { sinceDigest } from "../../web/studio/since.ts";
@@ -357,6 +358,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
357358 const crewOf = new Map(appCrew(f.app.db, apps.map((a) => a.repo)).map((c) => [c.repo.id, c] as const));
358359 const doings = clientDoings(f.app.db, ids);
359360 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);
360363 clientLines =
361364 apps.length === 0
362365 ? ["Your apps will appear here."]
@@ -372,6 +375,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
372375 const crew = crewOf.get(a.repo.id)!.says;
373376 return `${line}${doing === "" ? "" : ` ${doing}`} ${crew[0]!.toUpperCase()}${crew.slice(1)}.`;
374377 });
378 if (asked) clientLines = [askStatusLine(asked), ...clientLines];
375379 ctx.invalidate();
376380 };
377381
src/web/pages/next-steps.ts +19 −2
@@ -25,7 +25,8 @@ import { icon } from "../icons.ts";
2525 import { badge, btn, row } from "../ui.ts";
2626 import { everydayAsk } from "./everyday.ts";
2727 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";
2930 import { NEW_APP_TARGET } from "../studio/feed.ts";
3031 import { COMPOSER_FIELDS, TELL_MAX } from "../share_target.ts";
3132
@@ -41,6 +42,19 @@ export function crewAtWork(ctx: Ctx, repos: Awaited<ReturnType<typeof spawnedRep
4142 });
4243 }
4344
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
4458 /** One step: its row (title, "Done" trail; the primary one in bold), then its
4559 * 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.

4660 * no place for a form. */
@@ -100,6 +114,8 @@ export async function nextStepsCard(
100114 const crew = crewAtWork(ctx, repos);
101115 if (steps.some((s) => s.key === "tell" && !s.done)) return firstTell(ctx, user, crew);
102116 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;
103119 const bodies: Record<NextStep["key"], Html | null> = {
104120 everyday: html`<p class="muted small">${EVERYDAY_WHY}</p>
105121 ${everydayAsk(ctx, user)}`,
@@ -129,7 +145,8 @@ export async function nextStepsCard(
129145 };
130146 return html`<section id="next-steps">
131147 <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}
133150 ${steps.map((s) => stepBlock(s, s.key === primary, bodies[s.key]))}
134151 </section>`;
135152 }
src/web/studio/actions.ts +27 −20
@@ -8,6 +8,7 @@
88 * page that sent them, or JSON when the asks panel asked in place.
99 */
1010
11 import type { Routed } from "../../crew/route.ts";
1112 import { COMPOSER_FIELDS, claimShare, releaseShare } from "../share_target.ts";
1213 import { mayView } from "../../core/sealed.ts";
1314 import { holdPull, releasePull } from "../../actions/holds.ts";
@@ -87,7 +88,9 @@ function toldAnswer(
8788 told: Awaited<ReturnType<typeof tell>>,
8889 onTold: () => void,
8990 note = "",
91 subject = "",
9092 ): Promise<Response> | Response {
93 const named = (message: string): string => prefixed(message, subject);
9194 if (!told.ok && told.error === "body_too_long")
9295 return flashOrJson(ctx, back, {
9396 tone: "danger",
@@ -111,26 +114,28 @@ function toldAnswer(
111114 // The tell's own page, for a caller that asked for JSON (the palette's row).
112115 const at = { href: `${repoBase(repo)}/issues/${told.value.issue.number}` };
113116 // "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 {
116124 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) };
134139 }
135140
136141 /** 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,
240245 const first = await firstAsk(ctx.app, ctx.auth.user, field(form, COMPOSER_FIELDS.text), crewCanRun(ctx.app), from);
241246 if (!first.ok) return flashOrJson(ctx, home, { tone: "danger", message: first.error.words });
outdated

told.ok ? ... : 1 is a dead fallback that could name the wrong issue. Narrow on told.ok first. The flash has no test.

242247 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);
244251 }
245252 // "the forge" is no app: the words are kept as a thought, exactly as typed.
246253 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs