Public: anyone can read this; the crew can change it.
The first-five-minutes journey test proves a new forge's first ask reaches a running build within 60 seconds #12260
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11346-the-first-five-minutes-journey-test-prov → main 1 commit fork 1 thread · 1 unresolved
1 file
changed +152
−0
test/first-five-minutes-journey.test.ts added +152
| @@ -0,0 +1,152 @@ | ||
| 1 | /** | |
| 2 | * The first five minutes, the ask's half (#11346, part of #11158): on a | |
| 3 | * spawned forge whose owner has claimed it, the first ask, one sentence | |
| 4 | * through the form the home carries, reaches a running build within | |
| 5 | * `FIRST_FIVE_MINUTES.firstCrewActivitySeconds` (60 s): a new app exists, | |
| 6 | * the ask is an open issue on that app, a run on that issue is in progress, | |
| 7 | * and the home's "Your next steps" card names who is on it. The model is a | |
| 8 | * stand-in for Claude Code that stays at work; the parent, the child, the | |
| 9 | * claim, the tell, the crew and the dispatcher are the forge's own. The | |
| 10 | * verdict is `askProblems`, a pure read of what was found, and its own test | |
| 11 | * shows it red for an ask filed on the forge repository or only kept as a | |
| 12 | * thought. | |
| 13 | */ | |
| 14 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 15 | import { chmodSync, writeFileSync } from "node:fs"; | |
| 16 | import { join } from "node:path"; | |
| 17 | import { listAgents } from "../src/core/agents.ts"; | |
| 18 | import { listIssues } from "../src/core/issues.ts"; | |
| 19 | import { listReposOwnedBy } from "../src/core/repos.ts"; | |
| 20 | import { listRuns } from "../src/core/runs.ts"; | |
| 21 | import { recentThoughts } from "../src/core/thoughts.ts"; | |
| 22 | import { getUserByName } from "../src/core/users.ts"; | |
| 23 | import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts"; | |
| 24 | import { FIRST_FIVE_MINUTES } from "../src/design/first_five_minutes.ts"; | |
| 25 | import { claimedForge, type ClaimedForge } from "./helpers/claimed-forge.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | ||
| 28 | quietLogs(); | |
| 29 | ||
| 30 | /** What the journey found after the ask, as plain data. */ | |
| 31 | interface Found { | |
| 32 | /** Apps the owner has now that she did not before the ask. */ | |
| 33 | readonly newApps: readonly string[]; | |
| 34 | /** The repository the ask is an open issue on, as `owner/name`; undefined when none holds it. */ | |
| 35 | readonly issueOn: string | undefined; | |
| 36 | /** The forge's own repository (`forgeRepo`), where an ask must never land. */ | |
| 37 | readonly forgeRepo: string | undefined; | |
| 38 | /** Thoughts the ask was kept as. */ | |
| 39 | readonly thoughts: number; | |
| 40 | /** The state of the newest run on the ask's issue, if any. */ | |
| 41 | readonly runState: string | undefined; | |
| 42 | /** The home's next-steps card, as text. */ | |
| 43 | readonly card: string; | |
| 44 | /** The crew's usernames. */ | |
| 45 | readonly crew: readonly string[]; | |
| 46 | readonly seconds: number; | |
| 47 | } | |
| 48 | ||
| 49 | /** Every way the first ask can fail to reach a running build; empty when it does. */ | |
| 50 | function askProblems(f: Found): string[] { | |
| 51 | const out: string[] = []; | |
| 52 | if (f.newApps.length !== 1) out.push(`${f.newApps.length} new apps, wanted 1`); | |
| 53 | if (f.issueOn === undefined) out.push("the ask is no open issue anywhere"); | |
| 54 | else if (f.issueOn === f.forgeRepo) out.push("the ask was filed on the forge repository"); | |
| 55 | else if (!f.newApps.some((a) => f.issueOn?.endsWith(`/${a}`))) out.push(`the ask was filed on ${f.issueOn}, not the new app`); | |
| 56 | if (f.thoughts > 0) out.push("the ask was kept as a thought"); | |
| 57 | if (f.runState !== "running") out.push(`the run on the ask's issue is ${f.runState ?? "absent"}, not in progress`); | |
| 58 | if (!f.crew.some((who) => f.card.includes(`${who} is building it`))) out.push("the home card names no one on it"); | |
| 59 | if (f.seconds >= FIRST_FIVE_MINUTES.firstCrewActivitySeconds) out.push(`${f.seconds} s, budget ${FIRST_FIVE_MINUTES.firstCrewActivitySeconds}`); | |
| 60 | return out; | |
| 61 | } | |
| 62 | ||
| 63 | let forge: ClaimedForge; | |
| 64 | let crew: CrewHandle | undefined; | |
| 65 | let found: Found; | |
| 66 | ||
| 67 | beforeAll(async () => { | |
| 68 | forge = await claimedForge("forge-first-five-"); | |
| 69 | }, 60_000); | |
| 70 | afterAll(async () => { | |
| 71 | await crew?.stop(); | |
| 72 | await forge?.stop(); | |
| 73 | }); | |
| 74 | ||
| 75 | test("the first ask reaches a running build within 60 seconds, and the home says who is on it", async () => { | |
| 76 | const db = forge.child.db; | |
| 77 | const dir = forge.child.config.dataDir; | |
| 78 | const maria = getUserByName(db, "maria")!; | |
| 79 | const had = new Set(listReposOwnedBy(db, maria.id).map((r) => r.name)); | |
| 80 | // The stand-in model: it starts, announces itself and stays at work. | |
| 81 | const fake = join(dir, "fake-claude"); | |
| 82 | writeFileSync( | |
| 83 | fake, | |
| 84 | `#!/bin/sh | |
| 85 | if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi | |
| 86 | echo '{"type":"system","subtype":"init","session_id":"fake-1","model":"fake"}' | |
| 87 | sleep 25 | |
| 88 | `, | |
| 89 | ); | |
| 90 | chmodSync(fake, 0o755); | |
| 91 | ||
| 92 | const t0 = performance.now(); | |
| 93 | const ask = "A weekly menu page for the bakery"; | |
| 94 | const res = await forge.post(forge.owner, "/tell", { app: "new-app", text: ask }); | |
| 95 | expect(res.status).toBe(303); | |
| 96 | crew = await startCrew(forge.child, { | |
| 97 | forgeUrl: forge.base, | |
| 98 | localUrl: forge.base, | |
| 99 | binary: fake, | |
| 100 | concurrency: 1, | |
| 101 | budgetUsd: 1, | |
| 102 | runTimeoutMs: 40_000, | |
| 103 | networkRetryScale: 0, | |
| 104 | tickMs: 100, | |
| 105 | }); | |
| 106 | ||
| 107 | const read = async (): Promise<Found> => { | |
| 108 | const apps = listReposOwnedBy(db, maria.id).filter((r) => !had.has(r.name)); | |
| 109 | const issue = apps.flatMap((r) => listIssues(db, r.id, { state: "open" }).map((i) => ({ r, i }))).find(({ i }) => (i.title + i.body).includes(ask)); | |
| 110 | const run = issue === undefined ? undefined : listRuns(db, issue.r.id, { kind: "agent" })[0]; | |
| 111 | const home = await (await fetch(`${forge.base}/`, { headers: { cookie: forge.owner } })).text(); | |
| 112 | const card = /<section id="next-steps">([\s\S]*?)<\/section>/.exec(home)?.[1] ?? ""; | |
| 113 | return { | |
| 114 | newApps: apps.map((r) => r.name), | |
| 115 | issueOn: issue === undefined ? undefined : `${maria.username}/${issue.r.name}`, | |
| 116 | forgeRepo: forge.child.config.forgeRepo, | |
| 117 | thoughts: recentThoughts(db, maria.id, 10).length, | |
| 118 | runState: run?.state, | |
| 119 | card: card.replace(/<[^>]*>/g, " ").replace(/\s+/g, " "), | |
| 120 | crew: listAgents(db).map((a) => a.user.username), | |
| 121 | seconds: Math.ceil((performance.now() - t0) / 1000), | |
| 122 | }; | |
| 123 | }; | |
| 124 | const deadline = t0 + FIRST_FIVE_MINUTES.firstCrewActivitySeconds * 1000; | |
| 125 | found = await read(); | |
| 126 | while (askProblems(found).length > 0 && performance.now() < deadline) { | |
| 127 | await Bun.sleep(100); // real-timer: the forge's own crew runs on its own clock | |
| 128 | found = await read(); | |
| 129 | } | |
| 130 | expect(askProblems(found)).toEqual([]); | |
| 131 | // The ask went to a new app, not the forge's own repository. | |
| 132 | expect(found.issueOn).not.toBe(forge.child.config.forgeRepo); | |
| 133 | }, 90_000); | |
| 134 | ||
| 135 | test("the verdict is red for an ask filed on the forge repository, or only kept as a thought", () => { | |
| 136 | expect(askProblems(found)).toEqual([]); | |
| 137 | const forgeRepo = "maria/forge"; | |
| 138 | expect(askProblems({ ...found, forgeRepo, newApps: [], issueOn: forgeRepo })).toEqual([ | |
| 139 | "0 new apps, wanted 1", | |
| 140 | "the ask was filed on the forge repository", | |
| 141 | ]); | |
| 142 | expect(askProblems({ ...found, newApps: [], issueOn: undefined, thoughts: 1, runState: undefined, card: "" })).toEqual([ | |
| 143 | "0 new apps, wanted 1", | |
| 144 | "the ask is no open issue anywhere", | |
| 145 | "the ask was kept as a thought", | |
| 146 | "the run on the ask's issue is absent, not in progress", | |
| 147 | "the home card names no one on it", | |
| 148 | ]); | |
| 149 | expect(askProblems({ ...found, runState: "queued" })).toEqual(["the run on the ask's issue is queued, not in progress"]); | |
| 150 | expect(askProblems({ ...found, card: "Your next steps" })).toEqual(["the home card names no one on it"]); | |
| 151 | expect(askProblems({ ...found, seconds: 60 })).toEqual(["60 s, budget 60"]); | |
| 152 | }); |
new above
offline
Never true on the live read, because issueOn is only taken from new apps. See the first next step in the review body.